Skip to content

Commit e728d46

Browse files
Merge pull request #1743 from OceanParcels/better-prints
New and updated `reprs` for Variable, ParticleFile, Field, VectorField, and ParticleSet
2 parents bbe8448 + 709094c commit e728d46

File tree

10 files changed

+393
-47
lines changed

10 files changed

+393
-47
lines changed

docs/examples/parcels_tutorial.ipynb

Lines changed: 173 additions & 37 deletions
Large diffs are not rendered by default.

parcels/field.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
assert_valid_gridindexingtype,
2121
assert_valid_interp_method,
2222
)
23-
from parcels.tools._helpers import deprecated_made_private, timedelta_to_float
23+
from parcels.tools._helpers import default_repr, deprecated_made_private, field_repr, timedelta_to_float
2424
from parcels.tools.converters import (
2525
Geographic,
2626
GeographicPolar,
@@ -149,6 +149,8 @@ class Field:
149149
* `Nested Fields <../examples/tutorial_NestedFields.ipynb>`__
150150
"""
151151

152+
allow_time_extrapolation: bool
153+
time_periodic: TimePeriodic
152154
_cast_data_dtype: type[np.float32] | type[np.float64]
153155

154156
def __init__(
@@ -321,6 +323,9 @@ def __init__(
321323
if len(kwargs) > 0:
322324
raise SyntaxError(f'Field received an unexpected keyword argument "{list(kwargs.keys())[0]}"')
323325

326+
def __repr__(self) -> str:
327+
return field_repr(self)
328+
324329
@property
325330
@deprecated_made_private # TODO: Remove 6 months after v3.1.0
326331
def dataFiles(self):
@@ -1915,6 +1920,14 @@ def __init__(self, name: str, U: Field, V: Field, W: Field | None = None):
19151920
assert W.interp_method == "cgrid_velocity", "Interpolation methods of U and W are not the same."
19161921
assert self._check_grid_dimensions(U.grid, W.grid), "Dimensions of U and W are not the same."
19171922

1923+
def __repr__(self):
1924+
w_repr = default_repr(self.W) if self.W is not None else repr(self.W)
1925+
return f"""<{type(self).__name__}>
1926+
name: {self.name!r}
1927+
U: {default_repr(self.U)}
1928+
V: {default_repr(self.V)}
1929+
W: {w_repr}"""
1930+
19181931
@staticmethod
19191932
def _check_grid_dimensions(grid1, grid2):
19201933
return (

parcels/fieldset.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from parcels.grid import Grid
1515
from parcels.gridset import GridSet
1616
from parcels.particlefile import ParticleFile
17-
from parcels.tools._helpers import deprecated_made_private
17+
from parcels.tools._helpers import deprecated_made_private, fieldset_repr
1818
from parcels.tools.converters import TimeConverter, convert_xarray_time_units
1919
from parcels.tools.loggers import logger
2020
from parcels.tools.statuscodes import TimeExtrapolationError
@@ -56,6 +56,9 @@ def __init__(self, U: Field | NestedField | None, V: Field | NestedField | None,
5656
self.compute_on_defer = None
5757
self._add_UVfield()
5858

59+
def __repr__(self):
60+
return fieldset_repr(self)
61+
5962
@property
6063
def particlefile(self):
6164
return self._particlefile

parcels/particle.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def __set__(self, instance, value):
5353
setattr(instance, f"_{self.name}", value)
5454

5555
def __repr__(self):
56-
return f"PVar<{self.name}|{self.dtype}>"
56+
return f"Variable(name={self._name}, dtype={self.dtype}, initial={self.initial}, to_write={self.to_write})"
5757

5858
def is64bit(self):
5959
"""Check whether variable is 64-bit."""

parcels/particlefile.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import parcels
1212
from parcels._compat import MPI
13-
from parcels.tools._helpers import deprecated, deprecated_made_private, timedelta_to_float
13+
from parcels.tools._helpers import default_repr, deprecated, deprecated_made_private, timedelta_to_float
1414
from parcels.tools.warnings import FileWarning
1515

1616
__all__ = ["ParticleFile"]
@@ -116,6 +116,16 @@ def __init__(self, name, particleset, outputdt=np.inf, chunks=None, create_new_z
116116
fname = name if extension in [".zarr"] else f"{name}.zarr"
117117
self._fname = fname
118118

119+
def __repr__(self) -> str:
120+
return (
121+
f"{type(self).__name__}("
122+
f"name={self.fname!r}, "
123+
f"particleset={default_repr(self.particleset)}, "
124+
f"outputdt={self.outputdt!r}, "
125+
f"chunks={self.chunks!r}, "
126+
f"create_new_zarrfile={self.create_new_zarrfile!r})"
127+
)
128+
119129
@property
120130
def create_new_zarrfile(self):
121131
return self._create_new_zarrfile

parcels/particleset.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from parcels.particle import JITParticle, Variable
2828
from parcels.particledata import ParticleData, ParticleDataIterator
2929
from parcels.particlefile import ParticleFile
30-
from parcels.tools._helpers import deprecated, deprecated_made_private, timedelta_to_float
30+
from parcels.tools._helpers import deprecated, deprecated_made_private, particleset_repr, timedelta_to_float
3131
from parcels.tools.converters import _get_cftime_calendars, convert_to_flat_array
3232
from parcels.tools.global_statics import get_package_dir
3333
from parcels.tools.loggers import logger
@@ -112,6 +112,7 @@ def __init__(
112112
self.fieldset = fieldset
113113
self.fieldset._check_complete()
114114
self.time_origin = fieldset.time_origin
115+
self._pclass = pclass
115116

116117
# ==== first: create a new subclass of the pclass that includes the required variables ==== #
117118
# ==== see dynamic-instantiation trick here: https://www.python-course.eu/python3_classes_and_type.php ==== #
@@ -386,8 +387,12 @@ def size(self):
386387
# ==== to change at some point - len and size are different things ==== #
387388
return len(self.particledata)
388389

390+
@property
391+
def pclass(self):
392+
return self._pclass
393+
389394
def __repr__(self):
390-
return "\n".join([str(p) for p in self])
395+
return particleset_repr(self)
391396

392397
def __len__(self):
393398
return len(self.particledata)

parcels/tools/_helpers.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
"""Internal helpers for Parcels."""
22

3+
from __future__ import annotations
4+
35
import functools
6+
import textwrap
47
import warnings
58
from collections.abc import Callable
69
from datetime import timedelta
10+
from typing import TYPE_CHECKING, Any
711

812
import numpy as np
913

14+
if TYPE_CHECKING:
15+
from parcels import Field, FieldSet, ParticleSet
16+
1017
PACKAGE = "Parcels"
1118

1219

@@ -61,6 +68,77 @@ def patch_docstring(obj: Callable, extra: str) -> None:
6168
obj.__doc__ = f"{obj.__doc__ or ''}{extra}".strip()
6269

6370

71+
def field_repr(field: Field) -> str:
72+
"""Return a pretty repr for Field"""
73+
out = f"""<{type(field).__name__}>
74+
name : {field.name!r}
75+
grid : {field.grid!r}
76+
extrapolate time: {field.allow_time_extrapolation!r}
77+
time_periodic : {field.time_periodic!r}
78+
gridindexingtype: {field.gridindexingtype!r}
79+
to_write : {field.to_write!r}
80+
"""
81+
return textwrap.dedent(out).strip()
82+
83+
84+
def _format_list_items_multiline(items: list[str], level: int = 1) -> str:
85+
"""Given a list of strings, formats them across multiple lines.
86+
87+
Uses indentation levels of 4 spaces provided by ``level``.
88+
89+
Example
90+
-------
91+
>>> output = _format_list_items_multiline(["item1", "item2", "item3"], 4)
92+
>>> f"my_items: {output}"
93+
my_items: [
94+
item1,
95+
item2,
96+
item3,
97+
]
98+
"""
99+
if len(items) == 0:
100+
return "[]"
101+
102+
assert level >= 1, "Indentation level >=1 supported"
103+
indentation_str = level * 4 * " "
104+
indentation_str_end = (level - 1) * 4 * " "
105+
106+
items_str = ",\n".join([textwrap.indent(i, indentation_str) for i in items])
107+
return f"[\n{items_str}\n{indentation_str_end}]"
108+
109+
110+
def particleset_repr(pset: ParticleSet) -> str:
111+
"""Return a pretty repr for ParticleSet"""
112+
if len(pset) < 10:
113+
particles = [repr(p) for p in pset]
114+
else:
115+
particles = [repr(pset[i]) for i in range(7)] + ["..."]
116+
117+
out = f"""<{type(pset).__name__}>
118+
fieldset : {pset.fieldset}
119+
pclass : {pset.pclass}
120+
repeatdt : {pset.repeatdt}
121+
# particles: {len(pset)}
122+
particles : {_format_list_items_multiline(particles, level=2)}
123+
"""
124+
return textwrap.dedent(out).strip()
125+
126+
127+
def fieldset_repr(fieldset: FieldSet) -> str:
128+
"""Return a pretty repr for FieldSet"""
129+
fields_repr = "\n".join([repr(f) for f in fieldset.get_fields()])
130+
131+
out = f"""<{type(fieldset).__name__}>
132+
fields:
133+
{textwrap.indent(fields_repr, 8 * " ")}
134+
"""
135+
return textwrap.dedent(out).strip()
136+
137+
138+
def default_repr(obj: Any):
139+
return object.__repr__(obj)
140+
141+
64142
def timedelta_to_float(dt: float | timedelta | np.timedelta64) -> float:
65143
"""Convert a timedelta to a float in seconds."""
66144
if isinstance(dt, timedelta):

tests/test_reprs.py

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
import re
2+
from datetime import timedelta
13
from typing import Any
24

35
import numpy as np
46

5-
from parcels import Grid, TimeConverter
7+
import parcels
8+
from parcels import Grid, ParticleFile, TimeConverter, Variable
69
from parcels.grid import RectilinearGrid
10+
from tests.utils import create_fieldset_unit_mesh, create_simple_pset
711

812

9-
def validate_simple_repr(class_: type, kwargs: dict[str, Any]):
13+
def assert_simple_repr(class_: type, kwargs: dict[str, Any]):
1014
"""Test that the repr of an object contains all the arguments. This only works for objects where the repr matches the calling signature."""
1115
obj = class_(**kwargs)
1216
obj_repr = repr(obj)
@@ -17,12 +21,48 @@ def validate_simple_repr(class_: type, kwargs: dict[str, Any]):
1721
assert class_.__name__ in obj_repr
1822

1923

24+
def valid_indentation(s: str) -> bool:
25+
"""Make sure that all lines in string is indented with a multiple of 4 spaces."""
26+
if s.startswith(" "):
27+
return False
28+
29+
lines = s.split("\n")
30+
for line in lines:
31+
line = re.sub("^( {4})+", "", line)
32+
if line.startswith(" "):
33+
return False
34+
return True
35+
36+
37+
def test_check_indentation():
38+
valid = """
39+
test
40+
test
41+
test
42+
test
43+
test
44+
test"""
45+
assert valid_indentation(valid)
46+
invalid = """
47+
test
48+
test
49+
invalid!
50+
"""
51+
assert not valid_indentation(invalid)
52+
53+
2054
def test_grid_repr():
2155
"""Test arguments are in the repr of a Grid object"""
2256
kwargs = dict(
2357
lon=np.array([1, 2, 3]), lat=np.array([4, 5, 6]), time=None, time_origin=TimeConverter(), mesh="spherical"
2458
)
25-
validate_simple_repr(Grid, kwargs)
59+
assert_simple_repr(Grid, kwargs)
60+
61+
62+
def test_variable_repr():
63+
"""Test arguments are in the repr of the Variable object."""
64+
kwargs = dict(name="test", dtype=np.float32, initial=0, to_write=False)
65+
assert_simple_repr(Variable, kwargs)
2666

2767

2868
def test_rectilineargrid_repr():
@@ -34,4 +74,41 @@ def test_rectilineargrid_repr():
3474
kwargs = dict(
3575
lon=np.array([1, 2, 3]), lat=np.array([4, 5, 6]), time=None, time_origin=TimeConverter(), mesh="spherical"
3676
)
37-
validate_simple_repr(RectilinearGrid, kwargs)
77+
assert_simple_repr(RectilinearGrid, kwargs)
78+
79+
80+
def test_particlefile_repr():
81+
pset = create_simple_pset()
82+
kwargs = dict(
83+
name="file.zarr", particleset=pset, outputdt=timedelta(hours=1), chunks=None, create_new_zarrfile=False
84+
)
85+
assert_simple_repr(ParticleFile, kwargs)
86+
87+
88+
def test_field_repr():
89+
field = create_fieldset_unit_mesh().U
90+
assert valid_indentation(repr(field))
91+
92+
93+
def test_vectorfield_repr():
94+
field = create_fieldset_unit_mesh().UV
95+
assert isinstance(field, parcels.VectorField)
96+
assert valid_indentation(repr(field))
97+
98+
99+
def test_fieldset_repr():
100+
fieldset = create_fieldset_unit_mesh()
101+
assert valid_indentation(repr(fieldset))
102+
103+
104+
def test_particleset_repr():
105+
pset = create_simple_pset()
106+
valid_indentation(repr(pset))
107+
108+
pset = create_simple_pset(n=15)
109+
valid_indentation(repr(pset))
110+
111+
112+
def capture(s):
113+
with open("file.txt", "a") as f:
114+
f.write(s)

tests/tools/test_helpers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,20 @@
33
import numpy as np
44
import pytest
55

6+
import parcels.tools._helpers as helpers
67
from parcels.tools._helpers import deprecated, deprecated_made_private, timedelta_to_float
78

89

10+
def test_format_list_items_multiline():
11+
expected = """[
12+
item1,
13+
item2,
14+
item3
15+
]"""
16+
assert helpers._format_list_items_multiline(["item1", "item2", "item3"], 1) == expected
17+
assert helpers._format_list_items_multiline([], 1) == "[]"
18+
19+
920
def test_deprecated():
1021
class SomeClass:
1122
@deprecated()

tests/utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import numpy as np
66

7+
import parcels
78
from parcels import FieldSet
89

910
PROJECT_ROOT = Path(__file__).resolve().parents[1]
@@ -47,6 +48,18 @@ def create_fieldset_zeros_conversion(mesh="spherical", xdim=200, ydim=100, mesh_
4748
return FieldSet.from_data(data, dimensions, mesh=mesh)
4849

4950

51+
def create_simple_pset(n=1):
52+
zeros = np.zeros(n)
53+
return parcels.ParticleSet(
54+
fieldset=create_fieldset_unit_mesh(),
55+
pclass=parcels.ScipyParticle,
56+
lon=zeros,
57+
lat=zeros,
58+
depth=zeros,
59+
time=zeros,
60+
)
61+
62+
5063
def create_spherical_positions(n_particles, max_depth=100000):
5164
yrange = 2 * np.random.rand(n_particles)
5265
lat = 180 * (np.arccos(1 - yrange) - 0.5 * np.pi) / np.pi

0 commit comments

Comments
 (0)