roms-tools 0.0.2__py3-none-any.whl → 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ import xarray as xr
2
+ import numpy as np
3
+ import gcm_filters
4
+ from scipy.interpolate import RegularGridInterpolator
5
+ from scipy.ndimage import label
6
+ from roms_tools.setup.datasets import fetch_topo
7
+ import warnings
8
+ from itertools import count
9
+
10
+
11
+ def _add_topography_and_mask(
12
+ ds, topography_source, smooth_factor, hmin, rmax
13
+ ) -> xr.Dataset:
14
+ lon = ds.lon_rho.values
15
+ lat = ds.lat_rho.values
16
+
17
+ # interpolate topography onto desired grid
18
+ hraw = _make_raw_topography(lon, lat, topography_source)
19
+ hraw = xr.DataArray(data=hraw, dims=["eta_rho", "xi_rho"])
20
+
21
+ # Mask is obtained by finding locations where ocean depth is positive
22
+ mask = xr.where(hraw > 0, 1, 0)
23
+
24
+ # smooth topography domain-wide with Gaussian kernel to avoid grid scale instabilities
25
+ ds["hraw"] = _smooth_topography_globally(hraw, mask, smooth_factor)
26
+ ds["hraw"].attrs = {
27
+ "long_name": "Working bathymetry at rho-points",
28
+ "source": f"Raw bathymetry from {topography_source} (smoothing diameter {smooth_factor})",
29
+ "units": "meter",
30
+ }
31
+
32
+ # fill enclosed basins with land
33
+ mask = _fill_enclosed_basins(mask.values)
34
+ ds["mask_rho"] = xr.DataArray(mask, dims=("eta_rho", "xi_rho"))
35
+ ds["mask_rho"].attrs = {
36
+ "long_name": "Mask at rho-points",
37
+ "units": "land/water (0/1)",
38
+ }
39
+
40
+ # smooth topography locally to satisfy r < rmax
41
+ ds["h"] = _smooth_topography_locally(ds["hraw"] * ds["mask_rho"], hmin, rmax)
42
+ ds["h"].attrs = {
43
+ "long_name": "Final bathymetry at rho-points",
44
+ "units": "meter",
45
+ }
46
+
47
+ ds = _add_topography_metadata(ds, topography_source, smooth_factor, hmin, rmax)
48
+
49
+ return ds
50
+
51
+
52
+ def _make_raw_topography(lon, lat, topography_source) -> np.ndarray:
53
+ """
54
+ Given a grid of (lon, lat) points, fetch the topography file and interpolate height values onto the desired grid.
55
+ """
56
+
57
+ topo_ds = fetch_topo(topography_source)
58
+
59
+ # the following will depend on the topography source
60
+ if topography_source == "etopo5":
61
+ topo_lon = topo_ds["topo_lon"].copy()
62
+ # Modify longitude values where necessary
63
+ topo_lon = xr.where(topo_lon < 0, topo_lon + 360, topo_lon)
64
+ topo_lon_minus360 = topo_lon - 360
65
+ topo_lon_plus360 = topo_lon + 360
66
+ # Concatenate along the longitude axis
67
+ topo_lon_concatenated = xr.concat(
68
+ [topo_lon_minus360, topo_lon, topo_lon_plus360], dim="lon"
69
+ )
70
+ topo_concatenated = xr.concat(
71
+ [-topo_ds["topo"], -topo_ds["topo"], -topo_ds["topo"]], dim="lon"
72
+ )
73
+
74
+ interp = RegularGridInterpolator(
75
+ (topo_ds["topo_lat"].values, topo_lon_concatenated.values),
76
+ topo_concatenated.values,
77
+ method="linear",
78
+ )
79
+
80
+ # Interpolate onto desired domain grid points
81
+ hraw = interp((lat, lon))
82
+
83
+ return hraw
84
+
85
+
86
+ def _smooth_topography_globally(hraw, wet_mask, factor) -> xr.DataArray:
87
+ # since GCM-Filters assumes periodic domain, we extend the domain by one grid cell in each dimension
88
+ # and set that margin to land
89
+ margin_mask = xr.concat([wet_mask, 0 * wet_mask.isel(eta_rho=-1)], dim="eta_rho")
90
+ margin_mask = xr.concat(
91
+ [margin_mask, 0 * margin_mask.isel(xi_rho=-1)], dim="xi_rho"
92
+ )
93
+
94
+ # we choose a Gaussian filter kernel corresponding to a Gaussian with standard deviation factor/sqrt(12);
95
+ # this standard deviation matches the standard deviation of a boxcar kernel with total width equal to factor.
96
+ filter = gcm_filters.Filter(
97
+ filter_scale=factor,
98
+ dx_min=1,
99
+ filter_shape=gcm_filters.FilterShape.GAUSSIAN,
100
+ grid_type=gcm_filters.GridType.REGULAR_WITH_LAND,
101
+ grid_vars={"wet_mask": margin_mask},
102
+ )
103
+ hraw_extended = xr.concat([hraw, hraw.isel(eta_rho=-1)], dim="eta_rho")
104
+ hraw_extended = xr.concat(
105
+ [hraw_extended, hraw_extended.isel(xi_rho=-1)], dim="xi_rho"
106
+ )
107
+
108
+ hsmooth = filter.apply(hraw_extended, dims=["eta_rho", "xi_rho"])
109
+ hsmooth = hsmooth.isel(eta_rho=slice(None, -1), xi_rho=slice(None, -1))
110
+
111
+ return hsmooth
112
+
113
+
114
+ def _fill_enclosed_basins(mask) -> np.ndarray:
115
+ """
116
+ Fills in enclosed basins with land
117
+ """
118
+
119
+ # Label connected regions in the mask
120
+ reg, nreg = label(mask)
121
+ # Find the largest region
122
+ lint = 0
123
+ lreg = 0
124
+ for ireg in range(nreg):
125
+ int_ = np.sum(reg == ireg)
126
+ if int_ > lint and mask[reg == ireg].sum() > 0:
127
+ lreg = ireg
128
+ lint = int_
129
+
130
+ # Remove regions other than the largest one
131
+ for ireg in range(nreg):
132
+ if ireg != lreg:
133
+ mask[reg == ireg] = 0
134
+
135
+ return mask
136
+
137
+
138
+ def _smooth_topography_locally(h, hmin=5, rmax=0.2):
139
+ """
140
+ Smoothes topography locally to satisfy r < rmax
141
+ """
142
+ # Compute rmax_log
143
+ if rmax > 0.0:
144
+ rmax_log = np.log((1.0 + rmax * 0.9) / (1.0 - rmax * 0.9))
145
+ else:
146
+ rmax_log = 0.0
147
+
148
+ # Apply hmin threshold
149
+ h = xr.where(h < hmin, hmin, h)
150
+
151
+ # We will smooth logarithmically
152
+ h_log = np.log(h / hmin)
153
+
154
+ cf1 = 1.0 / 6
155
+ cf2 = 0.25
156
+
157
+ for iter in count():
158
+ # Compute gradients in domain interior
159
+
160
+ # in eta-direction
161
+ cff = h_log.diff("eta_rho").isel(xi_rho=slice(1, -1))
162
+ cr = np.abs(cff)
163
+ with warnings.catch_warnings():
164
+ warnings.simplefilter("ignore") # Ignore division by zero warning
165
+ Op1 = xr.where(cr < rmax_log, 0, 1.0 * cff * (1 - rmax_log / cr))
166
+
167
+ # in xi-direction
168
+ cff = h_log.diff("xi_rho").isel(eta_rho=slice(1, -1))
169
+ cr = np.abs(cff)
170
+ with warnings.catch_warnings():
171
+ warnings.simplefilter("ignore") # Ignore division by zero warning
172
+ Op2 = xr.where(cr < rmax_log, 0, 1.0 * cff * (1 - rmax_log / cr))
173
+
174
+ # in diagonal direction
175
+ cff = (h_log - h_log.shift(eta_rho=1, xi_rho=1)).isel(
176
+ eta_rho=slice(1, None), xi_rho=slice(1, None)
177
+ )
178
+ cr = np.abs(cff)
179
+ with warnings.catch_warnings():
180
+ warnings.simplefilter("ignore") # Ignore division by zero warning
181
+ Op3 = xr.where(cr < rmax_log, 0, 1.0 * cff * (1 - rmax_log / cr))
182
+
183
+ # in the other diagonal direction
184
+ cff = (h_log.shift(eta_rho=1) - h_log.shift(xi_rho=1)).isel(
185
+ eta_rho=slice(1, None), xi_rho=slice(1, None)
186
+ )
187
+ cr = np.abs(cff)
188
+ with warnings.catch_warnings():
189
+ warnings.simplefilter("ignore") # Ignore division by zero warning
190
+ Op4 = xr.where(cr < rmax_log, 0, 1.0 * cff * (1 - rmax_log / cr))
191
+
192
+ # Update h_log in domain interior
193
+ h_log[1:-1, 1:-1] += cf1 * (
194
+ Op1[1:, :]
195
+ - Op1[:-1, :]
196
+ + Op2[:, 1:]
197
+ - Op2[:, :-1]
198
+ + cf2 * (Op3[1:, 1:] - Op3[:-1, :-1] + Op4[:-1, 1:] - Op4[1:, :-1])
199
+ )
200
+
201
+ # No gradient at the domain boundaries
202
+ h_log[0, :] = h_log[1, :]
203
+ h_log[-1, :] = h_log[-2, :]
204
+ h_log[:, 0] = h_log[:, 1]
205
+ h_log[:, -1] = h_log[:, -2]
206
+
207
+ # Update h
208
+ h = hmin * np.exp(h_log)
209
+ # Apply hmin threshold again
210
+ h = xr.where(h < hmin, hmin, h)
211
+
212
+ # compute maximum slope parameter r
213
+ r_eta, r_xi = _compute_rfactor(h)
214
+ rmax0 = np.max([r_eta.max(), r_xi.max()])
215
+ if rmax0 < rmax:
216
+ break
217
+
218
+ return h
219
+
220
+
221
+ def _compute_rfactor(h):
222
+ """
223
+ Computes slope parameter (or r-factor) r = |Delta h| / 2h in both horizontal grid directions.
224
+ """
225
+ # compute r_{i-1/2} = |h_i - h_{i-1}| / (h_i + h_{i+1})
226
+ r_eta = np.abs(h.diff("eta_rho")) / (h + h.shift(eta_rho=1)).isel(
227
+ eta_rho=slice(1, None)
228
+ )
229
+ r_xi = np.abs(h.diff("xi_rho")) / (h + h.shift(xi_rho=1)).isel(
230
+ xi_rho=slice(1, None)
231
+ )
232
+
233
+ return r_eta, r_xi
234
+
235
+
236
+ def _add_topography_metadata(ds, topography_source, smooth_factor, hmin, rmax):
237
+ ds.attrs["topography_source"] = topography_source
238
+ ds.attrs["smooth_factor"] = smooth_factor
239
+ ds.attrs["hmin"] = hmin
240
+ ds.attrs["rmax"] = rmax
241
+
242
+ return ds
@@ -1,19 +1,25 @@
1
1
  import pytest
2
2
  import numpy as np
3
3
  import numpy.testing as npt
4
-
4
+ from scipy.ndimage import label
5
5
  from roms_tools import Grid
6
+ from roms_tools.setup.topography import _compute_rfactor
7
+ from roms_tools.setup.tides import TPXO
8
+ import os
9
+ import tempfile
6
10
 
7
11
 
8
12
  class TestCreateGrid:
9
13
  def test_simple_regression(self):
10
- grid = Grid(nx=1, ny=1, size_x=100, size_y=100, center_lon=-20, center_lat=0)
14
+ grid = Grid(
15
+ nx=1, ny=1, size_x=100, size_y=100, center_lon=-20, center_lat=0, rot=0
16
+ )
11
17
 
12
18
  expected_lat = np.array(
13
19
  [
14
- [1.79855429e00, 1.79855429e00, 1.79855429e00],
15
- [1.72818690e-14, 1.70960078e-14, 1.70960078e-14],
16
- [-1.79855429e00, -1.79855429e00, -1.79855429e00],
20
+ [-8.99249453e-01, -8.99249453e-01, -8.99249453e-01],
21
+ [0.0, 0.0, 0.0],
22
+ [8.99249453e-01, 8.99249453e-01, 8.99249453e-01],
17
23
  ]
18
24
  )
19
25
  expected_lon = np.array(
@@ -24,18 +30,18 @@ class TestCreateGrid:
24
30
  ]
25
31
  )
26
32
 
27
- npt.assert_allclose(grid.ds["lat_rho"], expected_lat)
28
- npt.assert_allclose(grid.ds["lon_rho"], expected_lon)
33
+ # TODO: adapt tolerances according to order of magnitude of respective fields
34
+ npt.assert_allclose(grid.ds["lat_rho"], expected_lat, atol=1e-8)
35
+ npt.assert_allclose(grid.ds["lon_rho"], expected_lon, atol=1e-8)
29
36
 
30
- def test_raise_if_crossing_dateline(self):
31
- with pytest.raises(ValueError, match="cannot cross Greenwich Meridian"):
32
- # test grid centered over London
33
- Grid(nx=3, ny=3, size_x=100, size_y=100, center_lon=0, center_lat=51.5)
37
+ def test_raise_if_domain_too_large(self):
38
+ with pytest.raises(ValueError, match="Domain size has to be smaller"):
39
+ Grid(nx=3, ny=3, size_x=30000, size_y=30000, center_lon=0, center_lat=51.5)
34
40
 
35
- # test Iceland grid which is rotated specifically to avoid Greenwich Meridian
41
+ # test grid with reasonable domain size
36
42
  grid = Grid(
37
- nx=100,
38
- ny=100,
43
+ nx=3,
44
+ ny=3,
39
45
  size_x=1800,
40
46
  size_y=2400,
41
47
  center_lon=-21,
@@ -44,11 +50,132 @@ class TestCreateGrid:
44
50
  )
45
51
  assert isinstance(grid, Grid)
46
52
 
53
+ def test_grid_straddle_crosses_meridian(self):
54
+ grid = Grid(
55
+ nx=3,
56
+ ny=3,
57
+ size_x=100,
58
+ size_y=100,
59
+ center_lon=0,
60
+ center_lat=61,
61
+ rot=20,
62
+ )
63
+ assert grid.straddle
64
+
65
+ grid = Grid(
66
+ nx=3,
67
+ ny=3,
68
+ size_x=100,
69
+ size_y=100,
70
+ center_lon=180,
71
+ center_lat=61,
72
+ rot=20,
73
+ )
74
+ assert not grid.straddle
47
75
 
48
- class TestGridFromFile:
49
- def test_equal_to_from_init(self):
50
- ...
51
76
 
77
+ class TestGridFromFile:
52
78
  def test_roundtrip(self):
53
79
  """Test that creating a grid, saving it to file, and re-opening it is the same as just creating it."""
54
- ...
80
+
81
+ # Initialize a Grid object using the initializer
82
+ grid_init = Grid(
83
+ nx=10,
84
+ ny=15,
85
+ size_x=100.0,
86
+ size_y=150.0,
87
+ center_lon=0.0,
88
+ center_lat=0.0,
89
+ rot=0.0,
90
+ topography_source="etopo5",
91
+ smooth_factor=2,
92
+ hmin=5.0,
93
+ rmax=0.2,
94
+ )
95
+
96
+ # Create a temporary file
97
+ with tempfile.NamedTemporaryFile(delete=False) as tmpfile:
98
+ filepath = tmpfile.name
99
+
100
+ try:
101
+ # Save the grid to a file
102
+ grid_init.save(filepath)
103
+
104
+ # Load the grid from the file
105
+ grid_from_file = Grid.from_file(filepath)
106
+
107
+ # Assert that the initial grid and the loaded grid are equivalent (including the 'ds' attribute)
108
+ assert grid_init == grid_from_file
109
+
110
+ finally:
111
+ os.remove(filepath)
112
+
113
+
114
+ class TestTopography:
115
+ def test_enclosed_regions(self):
116
+ """Test that there are only two connected regions, one dry and one wet."""
117
+
118
+ grid = Grid(
119
+ nx=100,
120
+ ny=100,
121
+ size_x=1800,
122
+ size_y=2400,
123
+ center_lon=30,
124
+ center_lat=61,
125
+ rot=20,
126
+ )
127
+
128
+ reg, nreg = label(grid.ds.mask_rho)
129
+ npt.assert_equal(nreg, 2)
130
+
131
+ def test_rmax_criterion(self):
132
+ grid = Grid(
133
+ nx=100,
134
+ ny=100,
135
+ size_x=1800,
136
+ size_y=2400,
137
+ center_lon=30,
138
+ center_lat=61,
139
+ rot=20,
140
+ smooth_factor=4,
141
+ rmax=0.2,
142
+ )
143
+ r_eta, r_xi = _compute_rfactor(grid.ds.h)
144
+ rmax0 = np.max([r_eta.max(), r_xi.max()])
145
+ npt.assert_array_less(rmax0, grid.rmax)
146
+
147
+ def test_hmin_criterion(self):
148
+ grid = Grid(
149
+ nx=100,
150
+ ny=100,
151
+ size_x=1800,
152
+ size_y=2400,
153
+ center_lon=30,
154
+ center_lat=61,
155
+ rot=20,
156
+ smooth_factor=2,
157
+ rmax=0.2,
158
+ hmin=5,
159
+ )
160
+
161
+ assert np.less_equal(grid.hmin, grid.ds.h.min())
162
+
163
+
164
+ class TestTPXO:
165
+ def test_load_data_file_not_found(self):
166
+ # Test loading data from a non-existing file
167
+ with pytest.raises(FileNotFoundError):
168
+ TPXO.load_data("non_existing_file.nc")
169
+
170
+ def test_load_data_checksum_mismatch(self):
171
+ # Create a temporary file for testing
172
+ filename = "test_tidal_data.nc"
173
+ with open(filename, "wb") as file:
174
+ # Write some data to the file
175
+ file.write(b"test data")
176
+ # Test loading data with incorrect checksum
177
+ with open(filename, "wb") as file:
178
+ with pytest.raises(ValueError):
179
+ TPXO.load_data(filename)
180
+ # Remove temporary file
181
+ os.remove(filename)
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.1
2
+ Name: roms-tools
3
+ Version: 0.1.0
4
+ Summary: Tools for running and analysing UCLA-ROMS simulations
5
+ Author-email: Nora Loose <nora.loose@gmail.com>, Thomas Nicholas <tom@cworthy.org>
6
+ License: Apache-2
7
+ Project-URL: Home, https://github.com/CWorthy-ocean/roms-tools
8
+ Project-URL: Documentation, https://roms-tools.readthedocs.io/en/latest/
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Topic :: Scientific/Engineering
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: xarray >=2022.6.0
22
+ Requires-Dist: numpy
23
+ Requires-Dist: netcdf4
24
+ Requires-Dist: pooch
25
+ Requires-Dist: matplotlib
26
+ Requires-Dist: cartopy
27
+ Requires-Dist: packaging
28
+ Requires-Dist: scipy
29
+ Requires-Dist: gcm-filters
30
+ Requires-Dist: numba
31
+
32
+ # ROMS-Tools
33
+
34
+ [![Documentation Status](https://readthedocs.org/projects/roms-tools/badge/?version=latest)](https://roms-tools.readthedocs.io/en/latest/?badge=latest)
35
+ [![PyPI version](https://badge.fury.io/py/roms-tools.svg)](https://badge.fury.io/py/roms-tools)
36
+
37
+ ## Overview
38
+
39
+ A suite of python tools for setting up a [ROMS](https://github.com/CESR-lab/ucla-roms) simulation.
40
+
41
+ ## Installation instructions
42
+
43
+ ### Installation from pip
44
+
45
+ ```bash
46
+ pip install roms-tools
47
+ ```
48
+
49
+ ### Installation from GitHub
50
+
51
+ To obtain the latest development version, clone the source repository and install it:
52
+
53
+ ```bash
54
+ git clone https://github.com/CWorthy-ocean/roms-tools.git
55
+ cd roms-tools
56
+ pip install -e .
57
+ ```
58
+
59
+
60
+ ### Run the tests
61
+
62
+ Before running the tests you can install and activate the following conda environment:
63
+
64
+ ```bash
65
+ cd roms-tools
66
+ conda env create -f ci/environment.yml
67
+ conda activate romstools
68
+ ```
69
+
70
+ Check the installation of `ROMS-Tools` has worked by running the test suite
71
+ ```bash
72
+ pytest
73
+ ```
74
+
75
+ ## Getting Started
76
+
77
+ To learn how to use `ROMS-Tools`, check out the [documentation](https://roms-tools.readthedocs.io/en/latest/).
78
+
79
+ ## Feedback and contributions
80
+
81
+ **If you find a bug, have a feature suggestion, or any other kind of feedback, please start a Discussion.**
82
+
83
+ We also accept contributions in the form of Pull Requests.
84
+
85
+
86
+ ## See also
87
+
88
+ - [ROMS source code](https://github.com/CESR-lab/ucla-roms)
89
+ - [C-Star](https://github.com/CWorthy-ocean/C-Star)
@@ -0,0 +1,17 @@
1
+ ci/environment.yml,sha256=OzTO5aTfzirFobAM6TmzJXZ622liCwIzreZH-x7n4mU,394
2
+ roms_tools/__init__.py,sha256=9MiZw2yYthIwOrktbOKwNptyZPmMV4_p4aCK4HymzUM,489
3
+ roms_tools/_version.py,sha256=FGsmFbZ942cZk42U_qHqxgRCbsD--5Vld_eR6xKJ-mQ,72
4
+ roms_tools/setup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ roms_tools/setup/atmospheric_forcing.py,sha256=A6wPmEfjeCIO3tu4TLt4Sul_J6AnF_peWZdON3I_6iw,34790
6
+ roms_tools/setup/datasets.py,sha256=0Hcsr7VzE-jo51kSMZ0ltHnIqc3fHe2v9lAC6ig2rH0,1943
7
+ roms_tools/setup/fill.py,sha256=8Klt77rkJqyXQ54TUv75KK_5iHcAwH-3bBtO3eoBieM,8701
8
+ roms_tools/setup/grid.py,sha256=p8AgCzUx5CkuxFcjFX6X65pYwEyOzxslx_nZDNJd-M4,26449
9
+ roms_tools/setup/plot.py,sha256=LZjTB4NHpXGsju_VZSCBwTfyox08nXHh6TkqmNBvz5Y,1809
10
+ roms_tools/setup/tides.py,sha256=aOez64Zv6MM1VFs1WIClKPpSg9vySvogqfqbg2eHKHA,20942
11
+ roms_tools/setup/topography.py,sha256=hj_IEbMTaazEtp6rrNbqAtGgPesWcCs8eHko0XHEhJg,8010
12
+ roms_tools/tests/test_setup.py,sha256=L90eM0IroRhYQ8ZY9BVOyUQND41lzdJKB_y2n7Aihmc,5102
13
+ roms_tools-0.1.0.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
14
+ roms_tools-0.1.0.dist-info/METADATA,sha256=XtN4Icf1h7Sh_ZbL9OFNCr9wIkvRpqTuo95szsLNtcg,2567
15
+ roms_tools-0.1.0.dist-info/WHEEL,sha256=Z4pYXqR_rTB7OWNDYFOm1qRk0RX6GFP2o8LgvP453Hk,91
16
+ roms_tools-0.1.0.dist-info/top_level.txt,sha256=aAf4T4nYQSkay5iKJ9kmTjlDgd4ETdp9OSlB4sJdt8Y,19
17
+ roms_tools-0.1.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.42.0)
2
+ Generator: setuptools (70.3.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5