agrigee-lite-client 3.4.1__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,25 @@
1
+ """Thin HTTP client for the AgriGEE.lite REST API. See SPECS.md."""
2
+
3
+ from agrigee_lite_client._async_client import AsyncAgriGEEClient
4
+ from agrigee_lite_client._client import AgriGEEClient
5
+ from agrigee_lite_client._exceptions import (
6
+ AgriGEEClientError,
7
+ AgriGEEHTTPError,
8
+ AgriGEEJobError,
9
+ AgriGEEJobTimeoutError,
10
+ AgriGEEUnknownSatelliteError,
11
+ AgriGEEVersionMismatchError,
12
+ )
13
+ from agrigee_lite_client._version import __version__
14
+
15
+ __all__ = [
16
+ "AgriGEEClient",
17
+ "AgriGEEClientError",
18
+ "AgriGEEHTTPError",
19
+ "AgriGEEJobError",
20
+ "AgriGEEJobTimeoutError",
21
+ "AgriGEEUnknownSatelliteError",
22
+ "AgriGEEVersionMismatchError",
23
+ "AsyncAgriGEEClient",
24
+ "__version__",
25
+ ]
@@ -0,0 +1,205 @@
1
+ """Asynchronous AsyncAgriGEEClient — mirrors _client.py. See SPECS.md §4.
2
+
3
+ Deliberately duplicated rather than unified with the sync client (see
4
+ SPECS.md §4): only the I/O (httpx calls, sleep) differs, and ``_get.py``
5
+ already carries every bit of logic that's actually shared.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import zipfile
12
+ from typing import Any
13
+
14
+ import httpx
15
+ import polars as pl
16
+ from shapely.geometry.base import BaseGeometry
17
+
18
+ from agrigee_lite_client import _geo_compat, _get, _satellite_dates, _validation
19
+ from agrigee_lite_client._client import _raise_for_status
20
+ from agrigee_lite_client._exceptions import AgriGEEJobError, AgriGEEJobTimeoutError, AgriGEEVersionMismatchError
21
+ from agrigee_lite_client._version import __version__
22
+
23
+
24
+ class _AsyncGet:
25
+ """Bound to a single ``AsyncAgriGEEClient`` — mirrors ``agl.get.*``."""
26
+
27
+ def __init__(self, client: AsyncAgriGEEClient) -> None:
28
+ self._client = client
29
+
30
+ async def sits(
31
+ self,
32
+ geometry: BaseGeometry,
33
+ start_date: str,
34
+ end_date: str,
35
+ satellite: str,
36
+ satellite_params: dict[str, Any] | None = None,
37
+ reducers: list[str] | None = None,
38
+ subsampling_max_pixels: float = 1_000,
39
+ ) -> pl.DataFrame:
40
+ """Naive: wraps a 1-row frame and calls ``multiple_sits`` (SPECS.md §6)."""
41
+ frame = _get.build_single_sits_frame(geometry, start_date, end_date)
42
+ return await self.multiple_sits(
43
+ frame,
44
+ satellite=satellite,
45
+ satellite_params=satellite_params,
46
+ reducers=reducers,
47
+ subsampling_max_pixels=subsampling_max_pixels,
48
+ )
49
+
50
+ async def multiple_sits(
51
+ self,
52
+ gdf,
53
+ satellite: str,
54
+ satellite_params: dict[str, Any] | None = None,
55
+ reducers: list[str] | None = None,
56
+ start_date_column: str = "start_date",
57
+ end_date_column: str = "end_date",
58
+ original_index_column: str = "original_index",
59
+ subsampling_max_pixels: float = 1_000,
60
+ chunksize: int = 10,
61
+ max_parallel_downloads: int = 40,
62
+ max_retries_per_chunk: int = 8,
63
+ force_redownload: bool = False,
64
+ ) -> pl.DataFrame:
65
+ await self._client._ensure_server_version_matches()
66
+
67
+ frame = _geo_compat.to_wkb_frame(gdf)
68
+ satellite_start, satellite_end = _satellite_dates.get_satellite_date_range(satellite, satellite_params)
69
+ validated = _validation.drop_rows_outside_satellite_range(
70
+ frame,
71
+ satellite_start=satellite_start,
72
+ satellite_end=satellite_end,
73
+ start_date_column=start_date_column,
74
+ end_date_column=end_date_column,
75
+ )
76
+
77
+ parquet_bytes, form = _get.build_multiple_sits_upload(
78
+ validated,
79
+ satellite=satellite,
80
+ satellite_params=satellite_params,
81
+ reducers=reducers,
82
+ start_date_column=start_date_column,
83
+ end_date_column=end_date_column,
84
+ original_index_column=original_index_column,
85
+ subsampling_max_pixels=subsampling_max_pixels,
86
+ chunksize=chunksize,
87
+ max_parallel_downloads=max_parallel_downloads,
88
+ max_retries_per_chunk=max_retries_per_chunk,
89
+ force_redownload=force_redownload,
90
+ )
91
+ response = await self._client._http.post(
92
+ "/sits/multiple/file",
93
+ files={"file": ("geometries.parquet", parquet_bytes, "application/octet-stream")},
94
+ data=form,
95
+ )
96
+ _raise_for_status(response)
97
+ job_id = response.json()["id"]
98
+ await self._client._wait_for_job(job_id)
99
+ content = await self._client._download_job(job_id)
100
+ return _get.parse_sits_download(content)
101
+
102
+ async def image(
103
+ self,
104
+ geometry: BaseGeometry,
105
+ start_date: str,
106
+ end_date: str,
107
+ satellite: str,
108
+ satellite_params: dict[str, Any] | None = None,
109
+ invalid_images_threshold: float = 0.5,
110
+ max_parallel_downloads: int = 40,
111
+ force_redownload: bool = False,
112
+ image_indices: list[int] | None = None,
113
+ scale: float | None = None,
114
+ dimensions: int | str | None = None,
115
+ ) -> zipfile.ZipFile:
116
+ body = _get.build_images_request(
117
+ geometry,
118
+ start_date=start_date,
119
+ end_date=end_date,
120
+ satellite=satellite,
121
+ satellite_params=satellite_params,
122
+ invalid_images_threshold=invalid_images_threshold,
123
+ max_parallel_downloads=max_parallel_downloads,
124
+ force_redownload=force_redownload,
125
+ image_indices=image_indices,
126
+ scale=scale,
127
+ dimensions=dimensions,
128
+ )
129
+ response = await self._client._http.post("/images", json=body)
130
+ _raise_for_status(response)
131
+ job_id = response.json()["id"]
132
+ await self._client._wait_for_job(job_id)
133
+ content = await self._client._download_job(job_id)
134
+ return _get.parse_image_download(content)
135
+
136
+
137
+ class AsyncAgriGEEClient:
138
+ """Asynchronous client for the AgriGEE.lite REST API. See SPECS.md."""
139
+
140
+ def __init__(
141
+ self,
142
+ base_url: str,
143
+ *,
144
+ timeout: float = 60.0,
145
+ poll_interval: float = 1.0,
146
+ poll_timeout: float | None = None,
147
+ ) -> None:
148
+ self._http = httpx.AsyncClient(base_url=base_url, timeout=timeout)
149
+ self._poll_interval = poll_interval
150
+ self._poll_timeout = poll_timeout
151
+ self._version_checked = False
152
+ self.get = _AsyncGet(self)
153
+
154
+ async def __aenter__(self) -> AsyncAgriGEEClient:
155
+ return self
156
+
157
+ async def __aexit__(self, *exc_info: object) -> None:
158
+ await self.aclose()
159
+
160
+ async def aclose(self) -> None:
161
+ await self._http.aclose()
162
+
163
+ async def health(self) -> bool:
164
+ response = await self._http.get("/health")
165
+ _raise_for_status(response)
166
+ return response.json().get("status") == "ok"
167
+
168
+ async def list_satellites(self) -> list[str]:
169
+ response = await self._http.get("/satellites")
170
+ _raise_for_status(response)
171
+ return response.json()
172
+
173
+ async def _ensure_server_version_matches(self) -> None:
174
+ """Checked once per instance, before trusting the embedded satellite
175
+ date table (_satellite_dates.py) — see AgriGEEVersionMismatchError.
176
+ """
177
+ if self._version_checked:
178
+ return
179
+ response = await self._http.get("/version")
180
+ _raise_for_status(response)
181
+ server_version = response.json().get("version")
182
+ if server_version != __version__:
183
+ raise AgriGEEVersionMismatchError(__version__, server_version)
184
+ self._version_checked = True
185
+
186
+ async def _wait_for_job(self, job_id: str) -> None:
187
+ elapsed = 0.0
188
+ while True:
189
+ response = await self._http.get(f"/jobs/{job_id}")
190
+ _raise_for_status(response)
191
+ job = response.json()
192
+ status = job["status"]
193
+ if status == "completed":
194
+ return
195
+ if status == "failed":
196
+ raise AgriGEEJobError(job_id, job.get("error"))
197
+ if self._poll_timeout is not None and elapsed >= self._poll_timeout:
198
+ raise AgriGEEJobTimeoutError(job_id, self._poll_timeout)
199
+ await asyncio.sleep(self._poll_interval)
200
+ elapsed += self._poll_interval
201
+
202
+ async def _download_job(self, job_id: str) -> bytes:
203
+ response = await self._http.get(f"/jobs/{job_id}/download")
204
+ _raise_for_status(response)
205
+ return response.content
@@ -0,0 +1,213 @@
1
+ """Synchronous AgriGEEClient. See SPECS.md §4 and §6."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import time
7
+ import zipfile
8
+ from typing import Any
9
+
10
+ import httpx
11
+ import polars as pl
12
+ from shapely.geometry.base import BaseGeometry
13
+
14
+ from agrigee_lite_client import _geo_compat, _get, _satellite_dates, _validation
15
+ from agrigee_lite_client._exceptions import (
16
+ AgriGEEHTTPError,
17
+ AgriGEEJobError,
18
+ AgriGEEJobTimeoutError,
19
+ AgriGEEVersionMismatchError,
20
+ )
21
+ from agrigee_lite_client._version import __version__
22
+
23
+
24
+ def _raise_for_status(response: httpx.Response) -> None:
25
+ if response.is_error:
26
+ detail = response.text
27
+ with contextlib.suppress(Exception):
28
+ detail = response.json().get("detail", detail)
29
+ raise AgriGEEHTTPError(response.status_code, detail)
30
+
31
+
32
+ class _SyncGet:
33
+ """Bound to a single ``AgriGEEClient`` — mirrors ``agl.get.*``."""
34
+
35
+ def __init__(self, client: AgriGEEClient) -> None:
36
+ self._client = client
37
+
38
+ def sits(
39
+ self,
40
+ geometry: BaseGeometry,
41
+ start_date: str,
42
+ end_date: str,
43
+ satellite: str,
44
+ satellite_params: dict[str, Any] | None = None,
45
+ reducers: list[str] | None = None,
46
+ subsampling_max_pixels: float = 1_000,
47
+ ) -> pl.DataFrame:
48
+ """Naive: wraps a 1-row frame and calls ``multiple_sits`` (SPECS.md §6)."""
49
+ frame = _get.build_single_sits_frame(geometry, start_date, end_date)
50
+ return self.multiple_sits(
51
+ frame,
52
+ satellite=satellite,
53
+ satellite_params=satellite_params,
54
+ reducers=reducers,
55
+ subsampling_max_pixels=subsampling_max_pixels,
56
+ )
57
+
58
+ def multiple_sits(
59
+ self,
60
+ gdf,
61
+ satellite: str,
62
+ satellite_params: dict[str, Any] | None = None,
63
+ reducers: list[str] | None = None,
64
+ start_date_column: str = "start_date",
65
+ end_date_column: str = "end_date",
66
+ original_index_column: str = "original_index",
67
+ subsampling_max_pixels: float = 1_000,
68
+ chunksize: int = 10,
69
+ max_parallel_downloads: int = 40,
70
+ max_retries_per_chunk: int = 8,
71
+ force_redownload: bool = False,
72
+ ) -> pl.DataFrame:
73
+ self._client._ensure_server_version_matches()
74
+
75
+ frame = _geo_compat.to_wkb_frame(gdf)
76
+ satellite_start, satellite_end = _satellite_dates.get_satellite_date_range(satellite, satellite_params)
77
+ validated = _validation.drop_rows_outside_satellite_range(
78
+ frame,
79
+ satellite_start=satellite_start,
80
+ satellite_end=satellite_end,
81
+ start_date_column=start_date_column,
82
+ end_date_column=end_date_column,
83
+ )
84
+
85
+ parquet_bytes, form = _get.build_multiple_sits_upload(
86
+ validated,
87
+ satellite=satellite,
88
+ satellite_params=satellite_params,
89
+ reducers=reducers,
90
+ start_date_column=start_date_column,
91
+ end_date_column=end_date_column,
92
+ original_index_column=original_index_column,
93
+ subsampling_max_pixels=subsampling_max_pixels,
94
+ chunksize=chunksize,
95
+ max_parallel_downloads=max_parallel_downloads,
96
+ max_retries_per_chunk=max_retries_per_chunk,
97
+ force_redownload=force_redownload,
98
+ )
99
+ response = self._client._http.post(
100
+ "/sits/multiple/file",
101
+ files={"file": ("geometries.parquet", parquet_bytes, "application/octet-stream")},
102
+ data=form,
103
+ )
104
+ _raise_for_status(response)
105
+ job_id = response.json()["id"]
106
+ self._client._wait_for_job(job_id)
107
+ content = self._client._download_job(job_id)
108
+ return _get.parse_sits_download(content)
109
+
110
+ def image(
111
+ self,
112
+ geometry: BaseGeometry,
113
+ start_date: str,
114
+ end_date: str,
115
+ satellite: str,
116
+ satellite_params: dict[str, Any] | None = None,
117
+ invalid_images_threshold: float = 0.5,
118
+ max_parallel_downloads: int = 40,
119
+ force_redownload: bool = False,
120
+ image_indices: list[int] | None = None,
121
+ scale: float | None = None,
122
+ dimensions: int | str | None = None,
123
+ ) -> zipfile.ZipFile:
124
+ body = _get.build_images_request(
125
+ geometry,
126
+ start_date=start_date,
127
+ end_date=end_date,
128
+ satellite=satellite,
129
+ satellite_params=satellite_params,
130
+ invalid_images_threshold=invalid_images_threshold,
131
+ max_parallel_downloads=max_parallel_downloads,
132
+ force_redownload=force_redownload,
133
+ image_indices=image_indices,
134
+ scale=scale,
135
+ dimensions=dimensions,
136
+ )
137
+ response = self._client._http.post("/images", json=body)
138
+ _raise_for_status(response)
139
+ job_id = response.json()["id"]
140
+ self._client._wait_for_job(job_id)
141
+ content = self._client._download_job(job_id)
142
+ return _get.parse_image_download(content)
143
+
144
+
145
+ class AgriGEEClient:
146
+ """Synchronous client for the AgriGEE.lite REST API. See SPECS.md."""
147
+
148
+ def __init__(
149
+ self,
150
+ base_url: str,
151
+ *,
152
+ timeout: float = 60.0,
153
+ poll_interval: float = 1.0,
154
+ poll_timeout: float | None = None,
155
+ ) -> None:
156
+ self._http = httpx.Client(base_url=base_url, timeout=timeout)
157
+ self._poll_interval = poll_interval
158
+ self._poll_timeout = poll_timeout
159
+ self._version_checked = False
160
+ self.get = _SyncGet(self)
161
+
162
+ def __enter__(self) -> AgriGEEClient:
163
+ return self
164
+
165
+ def __exit__(self, *exc_info: object) -> None:
166
+ self.close()
167
+
168
+ def close(self) -> None:
169
+ self._http.close()
170
+
171
+ def health(self) -> bool:
172
+ response = self._http.get("/health")
173
+ _raise_for_status(response)
174
+ return response.json().get("status") == "ok"
175
+
176
+ def list_satellites(self) -> list[str]:
177
+ response = self._http.get("/satellites")
178
+ _raise_for_status(response)
179
+ return response.json()
180
+
181
+ def _ensure_server_version_matches(self) -> None:
182
+ """Checked once per instance, before trusting the embedded satellite
183
+ date table (_satellite_dates.py) — see AgriGEEVersionMismatchError.
184
+ """
185
+ if self._version_checked:
186
+ return
187
+ response = self._http.get("/version")
188
+ _raise_for_status(response)
189
+ server_version = response.json().get("version")
190
+ if server_version != __version__:
191
+ raise AgriGEEVersionMismatchError(__version__, server_version)
192
+ self._version_checked = True
193
+
194
+ def _wait_for_job(self, job_id: str) -> None:
195
+ elapsed = 0.0
196
+ while True:
197
+ response = self._http.get(f"/jobs/{job_id}")
198
+ _raise_for_status(response)
199
+ job = response.json()
200
+ status = job["status"]
201
+ if status == "completed":
202
+ return
203
+ if status == "failed":
204
+ raise AgriGEEJobError(job_id, job.get("error"))
205
+ if self._poll_timeout is not None and elapsed >= self._poll_timeout:
206
+ raise AgriGEEJobTimeoutError(job_id, self._poll_timeout)
207
+ time.sleep(self._poll_interval)
208
+ elapsed += self._poll_interval
209
+
210
+ def _download_job(self, job_id: str) -> bytes:
211
+ response = self._http.get(f"/jobs/{job_id}/download")
212
+ _raise_for_status(response)
213
+ return response.content
@@ -0,0 +1,57 @@
1
+ """Exception hierarchy for agrigee_lite_client. See SPECS.md §7."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class AgriGEEClientError(Exception):
7
+ """Base class for every error this client raises."""
8
+
9
+
10
+ class AgriGEEHTTPError(AgriGEEClientError):
11
+ """The server returned an HTTP error status."""
12
+
13
+ def __init__(self, status_code: int, detail: str) -> None:
14
+ self.status_code = status_code
15
+ self.detail = detail
16
+ super().__init__(f"HTTP {status_code}: {detail}")
17
+
18
+
19
+ class AgriGEEJobError(AgriGEEClientError):
20
+ """A submitted job finished with status='failed'."""
21
+
22
+ def __init__(self, job_id: str, error: str | None) -> None:
23
+ self.job_id = job_id
24
+ self.error = error
25
+ super().__init__(f"Job {job_id} failed: {error}")
26
+
27
+
28
+ class AgriGEEJobTimeoutError(AgriGEEClientError):
29
+ """poll_timeout elapsed before the job reached a terminal status."""
30
+
31
+ def __init__(self, job_id: str, poll_timeout: float) -> None:
32
+ self.job_id = job_id
33
+ self.poll_timeout = poll_timeout
34
+ super().__init__(f"Job {job_id} did not finish within {poll_timeout}s")
35
+
36
+
37
+ class AgriGEEVersionMismatchError(AgriGEEClientError):
38
+ """The server's agrigee_lite version doesn't match this client's.
39
+
40
+ Raised before trusting the client's embedded satellite date table
41
+ (SPECS.md §5.5) — that table is a static copy of server-side data, only
42
+ guaranteed correct when both sides are on the same version.
43
+ """
44
+
45
+ def __init__(self, client_version: str, server_version: str | None) -> None:
46
+ self.client_version = client_version
47
+ self.server_version = server_version
48
+ reported = server_version if server_version is not None else "an unknown version (no 'version' in /version)"
49
+ super().__init__(
50
+ f"agrigee_lite_client is version {client_version} but the server is running {reported}. "
51
+ "Install matching versions of both — the client's satellite date table is only valid "
52
+ "when they agree."
53
+ )
54
+
55
+
56
+ class AgriGEEUnknownSatelliteError(AgriGEEClientError):
57
+ """A satellite name isn't in this client's embedded date-range table."""
@@ -0,0 +1,47 @@
1
+ """Accepts geopolars.GeoDataFrame, plain polars.DataFrame, or (if installed)
2
+ geopandas.GeoDataFrame as input to multiple_sits — geopandas is never a hard
3
+ dependency of this package (SPECS.md §3): it's detected lazily via
4
+ try/except ImportError, and only actually touched if the caller passes one.
5
+
6
+ geopolars.GeoDataFrame and plain polars.DataFrame already store their
7
+ geometry column as WKB Binary (verified against geopolars 0.1.0-alpha.4 —
8
+ see _geoparquet.py's module docstring), so both go straight to
9
+ build_geoparquet_bytes unchanged; only geopandas input needs converting.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import polars as pl
15
+
16
+
17
+ def _is_geopandas_geodataframe(frame: object) -> bool:
18
+ try:
19
+ import geopandas as gpd
20
+ except ImportError:
21
+ return False
22
+ return isinstance(frame, gpd.GeoDataFrame)
23
+
24
+
25
+ def _check_geopandas_crs_is_wgs84(frame) -> None:
26
+ if frame.crs is not None and frame.crs != "EPSG:4326":
27
+ msg = (
28
+ f"Geometry CRS is {frame.crs!r}, but agrigee_lite_client never reprojects "
29
+ "(SPECS.md §1) — reproject to EPSG:4326 yourself before calling multiple_sits/sits, "
30
+ "e.g. `gdf.to_crs('EPSG:4326')`."
31
+ )
32
+ raise ValueError(msg)
33
+
34
+
35
+ def to_wkb_frame(frame, *, geometry_column: str = "geometry") -> pl.DataFrame:
36
+ """Normalize any accepted frame type to a plain polars.DataFrame whose
37
+ ``geometry_column`` holds WKB bytes, ready for build_geoparquet_bytes.
38
+ """
39
+ if _is_geopandas_geodataframe(frame):
40
+ import shapely
41
+
42
+ _check_geopandas_crs_is_wgs84(frame)
43
+ wkb = shapely.to_wkb(frame[geometry_column].to_numpy())
44
+ rest = pl.from_pandas(frame.drop(columns=[geometry_column]))
45
+ return rest.with_columns(pl.Series(geometry_column, wkb))
46
+
47
+ return frame
@@ -0,0 +1,66 @@
1
+ """GeoParquet encoding/decoding without geopandas. See SPECS.md §5.1.
2
+
3
+ geopolars 0.1.0-alpha.4 does not write GeoParquet file metadata on its own:
4
+ ``GeoDataFrame.write_parquet`` is the plain ``polars.DataFrame.write_parquet``
5
+ (inherited, not overridden), and a geometry column round-trips through Arrow
6
+ as a bare ``Binary`` array with no CRS or extension type attached. The server
7
+ decodes uploads with ``geopandas.read_parquet``, which looks for a ``"geo"``
8
+ key in the Parquet file-level metadata
9
+ (https://geoparquet.org/releases/v1.0.0/), so this module builds that key by
10
+ hand — verified end-to-end against ``geopandas.read_parquet`` in
11
+ ``tests/test_geoparquet.py``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import json
18
+ from collections.abc import Iterable
19
+
20
+ import polars as pl
21
+ import pyarrow.parquet as pq
22
+ import shapely
23
+ from shapely.geometry.base import BaseGeometry
24
+
25
+ _GEOPARQUET_VERSION = "1.0.0"
26
+
27
+
28
+ def _geo_metadata(primary_column: str) -> bytes:
29
+ return json.dumps(
30
+ {
31
+ "version": _GEOPARQUET_VERSION,
32
+ "primary_column": primary_column,
33
+ "columns": {
34
+ # geometry_types=[] is valid per spec ("not specified") — this
35
+ # client never introspects per-row geometry types.
36
+ # crs is decorative here: the server takes the authoritative
37
+ # CRS from the `crs` multipart form field, not this metadata
38
+ # (see SPECS.md §5.1). Geometries must already be WGS84.
39
+ primary_column: {"encoding": "WKB", "geometry_types": [], "crs": "OGC:CRS84"},
40
+ },
41
+ }
42
+ ).encode()
43
+
44
+
45
+ def encode_geometries(geometries: Iterable[BaseGeometry]) -> list[bytes]:
46
+ """Serialize shapely geometries to WKB bytes for a Binary parquet column."""
47
+ return [shapely.to_wkb(geometry) for geometry in geometries]
48
+
49
+
50
+ def build_geoparquet_bytes(frame, *, geometry_column: str = "geometry") -> bytes:
51
+ """Serialize a geopolars.GeoDataFrame or polars.DataFrame to GeoParquet bytes.
52
+
53
+ ``geometry_column`` must already hold WKB bytes (``polars.Binary``) —
54
+ build it with ``encode_geometries`` first.
55
+ """
56
+ table = frame.to_arrow()
57
+ existing_metadata = table.schema.metadata or {}
58
+ table = table.replace_schema_metadata({**existing_metadata, b"geo": _geo_metadata(geometry_column)})
59
+ buf = io.BytesIO()
60
+ pq.write_table(table, buf)
61
+ return buf.getvalue()
62
+
63
+
64
+ def read_plain_parquet_bytes(content: bytes) -> pl.DataFrame:
65
+ """Read a plain (non-geo) parquet response, e.g. a SITS job download."""
66
+ return pl.read_parquet(io.BytesIO(content))
@@ -0,0 +1,111 @@
1
+ """Pure request/response helpers shared by the sync and async clients.
2
+
3
+ Nothing in this module does I/O — it only builds the bytes/dicts to send and
4
+ parses the bytes that come back. The httpx calls and the job poll loop live
5
+ in ``_client.py`` / ``_async_client.py``, since that's the only part that
6
+ actually needs to differ between sync and async (see SPECS.md §4, §8).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import json
13
+ import zipfile
14
+ from typing import Any
15
+
16
+ import polars as pl
17
+ from shapely.geometry import mapping
18
+ from shapely.geometry.base import BaseGeometry
19
+
20
+ from agrigee_lite_client import _geoparquet
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # sits / multiple_sits
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ def build_single_sits_frame(geometry: BaseGeometry, start_date: str, end_date: str) -> pl.DataFrame:
28
+ """1-row frame used by ``get.sits()`` — see SPECS.md §6 ("naive puro")."""
29
+ wkb = _geoparquet.encode_geometries([geometry])
30
+ return pl.DataFrame({"start_date": [start_date], "end_date": [end_date], "geometry": wkb})
31
+
32
+
33
+ def build_multiple_sits_upload(
34
+ frame,
35
+ *,
36
+ satellite: str,
37
+ satellite_params: dict[str, Any] | None,
38
+ reducers: list[str] | None,
39
+ start_date_column: str,
40
+ end_date_column: str,
41
+ original_index_column: str,
42
+ subsampling_max_pixels: float,
43
+ chunksize: int,
44
+ max_parallel_downloads: int,
45
+ max_retries_per_chunk: int,
46
+ force_redownload: bool,
47
+ ) -> tuple[bytes, dict[str, str]]:
48
+ """Build the multipart body for ``POST /sits/multiple/file``.
49
+
50
+ Returns ``(parquet_bytes, form_fields)``; ``form_fields`` excludes the
51
+ file itself, which the caller attaches separately as multipart ``file``.
52
+ CRS is always ``EPSG:4326`` — this client never reprojects (SPECS.md §1).
53
+ """
54
+ parquet_bytes = _geoparquet.build_geoparquet_bytes(frame)
55
+ form: dict[str, str] = {
56
+ "satellite": json.dumps({"name": satellite, "params": satellite_params or {}}),
57
+ "start_date_column": start_date_column,
58
+ "end_date_column": end_date_column,
59
+ "original_index_column": original_index_column,
60
+ "subsampling_max_pixels": str(subsampling_max_pixels),
61
+ "chunksize": str(chunksize),
62
+ "max_parallel_downloads": str(max_parallel_downloads),
63
+ "max_retries_per_chunk": str(max_retries_per_chunk),
64
+ "force_redownload": str(force_redownload).lower(),
65
+ "crs": "EPSG:4326",
66
+ }
67
+ if reducers is not None:
68
+ form["reducers"] = json.dumps(reducers)
69
+ return parquet_bytes, form
70
+
71
+
72
+ def parse_sits_download(content: bytes) -> pl.DataFrame:
73
+ return _geoparquet.read_plain_parquet_bytes(content)
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # image
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ def build_images_request(
82
+ geometry: BaseGeometry,
83
+ *,
84
+ start_date: str,
85
+ end_date: str,
86
+ satellite: str,
87
+ satellite_params: dict[str, Any] | None,
88
+ invalid_images_threshold: float,
89
+ max_parallel_downloads: int,
90
+ force_redownload: bool,
91
+ image_indices: list[int] | None,
92
+ scale: float | None,
93
+ dimensions: int | str | None,
94
+ ) -> dict[str, Any]:
95
+ """JSON body for ``POST /images`` — a single geometry, no parquet needed."""
96
+ return {
97
+ "geometry": mapping(geometry),
98
+ "start_date": start_date,
99
+ "end_date": end_date,
100
+ "satellite": {"name": satellite, "params": satellite_params or {}},
101
+ "invalid_images_threshold": invalid_images_threshold,
102
+ "max_parallel_downloads": max_parallel_downloads,
103
+ "force_redownload": force_redownload,
104
+ "image_indices": image_indices,
105
+ "scale": scale,
106
+ "dimensions": dimensions,
107
+ }
108
+
109
+
110
+ def parse_image_download(content: bytes) -> zipfile.ZipFile:
111
+ return zipfile.ZipFile(io.BytesIO(content))
@@ -0,0 +1,95 @@
1
+ """Static table of every satellite's temporal range (startDate/endDate).
2
+
3
+ Generated by scripts/generate_satellite_dates.py from the server's own
4
+ agrigee_lite.api._satellites.REGISTRY — do not hand-edit. Regenerate whenever
5
+ a satellite's date range changes or one is added/removed, then bump both
6
+ packages' version together (SPECS.md §2.1); test_satellite_dates_match_server
7
+ catches drift whenever agrigee_lite happens to be importable alongside this
8
+ package (i.e. in this monorepo's dev environment).
9
+
10
+ `TwoSatelliteFusion` is intentionally absent: its constructor takes two
11
+ satellite *objects*, not JSON-serializable params, so it was never reachable
12
+ through the `{name, params}` contract this table (and the REST API's
13
+ SatelliteSpec) is built around.
14
+
15
+ Sentinel2 is the only satellite whose range depends on a constructor param
16
+ (`use_sr`) rather than being fixed per class — handled explicitly in
17
+ get_satellite_date_range() below rather than complicating this table's shape
18
+ for one exception.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from agrigee_lite_client._exceptions import AgriGEEUnknownSatelliteError
24
+
25
+ SATELLITE_DATE_RANGES: dict[str, tuple[str, str]] = {
26
+ 'AHN2Interpolated': ('1900-01-01', '2050-01-01'),
27
+ 'AHN2NonInterpolated': ('1900-01-01', '2050-01-01'),
28
+ 'AHN2Raw': ('1900-01-01', '2050-01-01'),
29
+ 'AHN3': ('1900-01-01', '2050-01-01'),
30
+ 'AHN4': ('1900-01-01', '2050-01-01'),
31
+ 'ALOSWorld3D': ('1900-01-01', '2050-01-01'),
32
+ 'ANADEM': ('1900-01-01', '2050-01-01'),
33
+ 'ASTERDEM': ('1900-01-01', '2050-01-01'),
34
+ 'ArcticDEMMosaic': ('1900-01-01', '2050-01-01'),
35
+ 'ArcticDEMStrips': ('1900-01-01', '2050-01-01'),
36
+ 'Australia5mDEM': ('1900-01-01', '2050-01-01'),
37
+ 'AustraliaDEMH': ('1900-01-01', '2050-01-01'),
38
+ 'AustraliaDEMS': ('1900-01-01', '2050-01-01'),
39
+ 'CGIARSRTM': ('1900-01-01', '2050-01-01'),
40
+ 'CanadaCDEM': ('1900-01-01', '2050-01-01'),
41
+ 'CopernicusDEM': ('1900-01-01', '2050-01-01'),
42
+ 'CryoSat2Antarctica': ('1900-01-01', '2050-01-01'),
43
+ 'ETOPO1': ('1900-01-01', '2050-01-01'),
44
+ 'England1mTerrain': ('1900-01-01', '2050-01-01'),
45
+ 'FranceRGEAlti': ('1900-01-01', '2050-01-01'),
46
+ 'GLOBathy': ('1900-01-01', '2050-01-01'),
47
+ 'GMTED2010': ('1900-01-01', '2050-01-01'),
48
+ 'GTOPO30': ('1900-01-01', '2050-01-01'),
49
+ 'GreenlandGIMP': ('1900-01-01', '2050-01-01'),
50
+ 'HLSLandsat': ('2013-04-11', '2050-01-01'),
51
+ 'HLSSentinel2': ('2015-11-30', '2050-01-01'),
52
+ 'Landsat5': ('1984-03-01', '2013-05-05'),
53
+ 'Landsat7': ('1999-04-15', '2022-04-06'),
54
+ 'Landsat8': ('2013-04-11', '2050-01-01'),
55
+ 'Landsat9': ('2021-11-01', '2050-01-01'),
56
+ 'MERITDEM': ('1900-01-01', '2050-01-01'),
57
+ 'MapBiomas': ('1985-01-01', '2024-01-01'),
58
+ 'Modis8Days': ('2000-02-18', '2050-01-01'),
59
+ 'ModisDaily': ('2000-02-24', '2050-01-01'),
60
+ 'NAIP': ('2003-01-01', '2050-01-01'),
61
+ 'NASADEM': ('1900-01-01', '2050-01-01'),
62
+ 'NEONDEM': ('1900-01-01', '2050-01-01'),
63
+ 'PALSAR2ScanSAR': ('2014-08-04', '2050-01-01'),
64
+ 'PolarisSoilTexture': ('1900-01-01', '2050-01-01'),
65
+ 'REMAMosaic': ('1900-01-01', '2050-01-01'),
66
+ 'REMAStrips2m': ('1900-01-01', '2050-01-01'),
67
+ 'REMAStrips8m': ('1900-01-01', '2050-01-01'),
68
+ 'SRTM': ('1900-01-01', '2050-01-01'),
69
+ 'SatelliteEmbedding': ('2017-01-01', '2024-01-01'),
70
+ 'Sentinel1GRD': ('2014-10-03', '2050-01-01'),
71
+ 'Sentinel2': ('2019-01-01', '2050-01-01'),
72
+ 'USGS3DEP10m': ('1900-01-01', '2050-01-01'),
73
+ 'USGS3DEP1m': ('1900-01-01', '2050-01-01'),
74
+ 'WRBSoilClasses': ('1900-01-01', '2050-01-01'),
75
+ }
76
+
77
+
78
+ _SENTINEL2_USE_SR_FALSE = ("2016-01-01", "2050-01-01")
79
+
80
+
81
+ def get_satellite_date_range(name: str, params: dict | None = None) -> tuple[str, str]:
82
+ """(start_date, end_date) for a satellite + params, mirroring the value
83
+ the server's own satellite class would report for the same inputs.
84
+ """
85
+ if name == "Sentinel2" and params and params.get("use_sr") is False:
86
+ return _SENTINEL2_USE_SR_FALSE
87
+
88
+ date_range = SATELLITE_DATE_RANGES.get(name)
89
+ if date_range is None:
90
+ msg = (
91
+ f"agrigee_lite_client does not know satellite {name!r}. Either it doesn't exist, or this "
92
+ "client's version has drifted from the server's — see SPECS.md §2.1."
93
+ )
94
+ raise AgriGEEUnknownSatelliteError(msg)
95
+ return date_range
@@ -0,0 +1,91 @@
1
+ """Client-side mirror of the server's date/satellite-range validation.
2
+
3
+ Mirrors agrigee_lite/get/sits.py: sanitize_and_prepare_input_gdf — the masking,
4
+ dropping, and logging around rows whose [start_date, end_date) doesn't
5
+ intersect the satellite's temporal range. Applying this before ever opening a
6
+ connection means an impossible request fails immediately and locally instead
7
+ of round-tripping to the server first (and, for `multiple_sits`, before
8
+ uploading a GeoParquet full of rows the server would just drop anyway).
9
+
10
+ H3 clustering (also part of the server function) is intentionally not
11
+ mirrored here — it's a chunking/performance detail for the server's own
12
+ download scheduling, not part of request validity.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from datetime import datetime
19
+
20
+ import polars as pl
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _as_datetime_expr(frame: pl.DataFrame, column: str) -> pl.Expr:
26
+ """pl.col(column).cast(pl.Datetime) silently turns every value into null
27
+ for a String column as of polars 1.43 (the cast used to parse; that
28
+ behavior is deprecated/gone, str.to_datetime() is the replacement) — a
29
+ trap the server itself never hits because it always converts date
30
+ columns with pandas.to_datetime() before this kind of check runs, but
31
+ this client hands raw ISO strings straight from sits()/multiple_sits()
32
+ callers.
33
+ """
34
+ if frame.schema[column] == pl.Utf8:
35
+ return pl.col(column).str.to_datetime(strict=False)
36
+ return pl.col(column).cast(pl.Datetime, strict=False)
37
+
38
+
39
+ def drop_rows_outside_satellite_range(
40
+ frame: pl.DataFrame,
41
+ *,
42
+ satellite_start: str,
43
+ satellite_end: str,
44
+ start_date_column: str,
45
+ end_date_column: str,
46
+ ) -> pl.DataFrame:
47
+ """Drop rows that don't intersect [satellite_start, satellite_end).
48
+
49
+ Raises ValueError if *none* of the rows intersect (mirrors the server's
50
+ behavior exactly). A row with start_date == end_date is dropped too —
51
+ Earth Engine's date filter treats the end date as exclusive, so a
52
+ zero-width window is never valid.
53
+ """
54
+ working = frame.with_columns(
55
+ _as_datetime_expr(frame, start_date_column).alias(start_date_column),
56
+ _as_datetime_expr(frame, end_date_column).alias(end_date_column),
57
+ )
58
+
59
+ sat_start = datetime.fromisoformat(satellite_start)
60
+ sat_end = datetime.fromisoformat(satellite_end)
61
+
62
+ working = working.with_columns(
63
+ (
64
+ (pl.col(end_date_column) < pl.lit(sat_start)) | (pl.col(start_date_column) > pl.lit(sat_end))
65
+ ).alias("_mask_no_intersection"),
66
+ (pl.col(start_date_column) == pl.col(end_date_column)).alias("_mask_zero_width"),
67
+ )
68
+
69
+ height = working.height
70
+ count_none = int(working.get_column("_mask_no_intersection").sum())
71
+ count_zero_width = int(working.get_column("_mask_zero_width").sum())
72
+
73
+ pct_none = 100 * count_none / height
74
+ if pct_none > 0:
75
+ logger.warning("%.2f%% of the data do not intersect the satellite period.", pct_none)
76
+
77
+ if count_zero_width > 0:
78
+ pct_zero_width = 100 * count_zero_width / height
79
+ logger.warning(
80
+ "%d row(s) (%.2f%%) have start_date == end_date and were dropped: Earth Engine's date "
81
+ "filter treats the end date as exclusive, so a zero-width range is not supported.",
82
+ count_zero_width,
83
+ pct_zero_width,
84
+ )
85
+
86
+ if count_none == height:
87
+ msg = f"None of the requested periods intersect the satellite's temporal range ({satellite_start} to {satellite_end})."
88
+ raise ValueError(msg)
89
+
90
+ drop_mask = pl.col("_mask_no_intersection") | pl.col("_mask_zero_width")
91
+ return working.filter(~drop_mask).drop("_mask_no_intersection", "_mask_zero_width")
@@ -0,0 +1,10 @@
1
+ """Single source of truth for this package's version at import time.
2
+
3
+ Kept separate from __init__.py so _client.py/_async_client.py can import it
4
+ without a circular import (they're imported BY __init__.py). Must always
5
+ equal pyproject.toml's version (SPECS.md §2.1) and, transitively, the
6
+ server's — see also the embedded satellite date table in _satellite_dates.py,
7
+ which is only valid when client and server versions agree.
8
+ """
9
+
10
+ __version__ = "3.4.1"
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.5
2
+ Name: agrigee_lite_client
3
+ Version: 3.4.1
4
+ Summary: Thin HTTP client for the AgriGEE.lite REST API — no Earth Engine, no GDAL, no DuckDB.
5
+ Project-URL: Homepage, https://mateuspinto.github.io/agrigee_lite/
6
+ Project-URL: Repository, https://github.com/mateuspinto/agrigee_lite
7
+ Project-URL: Documentation, https://mateuspinto.github.io/agrigee_lite/
8
+ Author-email: Mateus Pinto da Silva <mateus.p.silva@ufv.br>
9
+ Keywords: python
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: <4.0,>=3.10
19
+ Requires-Dist: geopolars==0.1.0a4
20
+ Requires-Dist: httpx>=0.27.0
21
+ Requires-Dist: shapely>=2.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: geopandas>=1.0.1; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
25
+ Requires-Dist: ruff>=0.11.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # agrigee_lite_client
29
+
30
+ Thin HTTP client for the [`agrigee_lite[api]`](../README.md) server. Doesn't
31
+ install Earth Engine, GDAL/PROJ, or DuckDB — it only knows how to build a
32
+ request, upload/download bytes, and talk to `/jobs`. See
33
+ [`SPECS.md`](SPECS.md) for the full design and the reasoning behind it.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install agrigee_lite_client
39
+ ```
40
+
41
+ One fixed dependency set, always — `geopolars`, `shapely`, and `httpx`.
42
+ There's no install variation (no `[async]`, `[shapely]`, etc. extras): the
43
+ same install already covers both sync and async use.
44
+
45
+ ## Quickstart — sync
46
+
47
+ ```python
48
+ from agrigee_lite_client import AgriGEEClient
49
+ from shapely.geometry import Polygon
50
+
51
+ geometry = Polygon([(-56.42, -11.20), (-56.41, -11.20), (-56.41, -11.19)])
52
+
53
+ with AgriGEEClient("http://192.168.3.204:8100") as client:
54
+ # a single geometry — naive: internally becomes a 1-row multiple_sits
55
+ df = client.get.sits(geometry, "2023-01-01", "2023-06-01", satellite="Sentinel2")
56
+
57
+ # multiple geometries — uploads a GeoParquet, polls the job, downloads the result parquet
58
+ import polars as pl
59
+ from agrigee_lite_client._geoparquet import encode_geometries
60
+
61
+ gdf = pl.DataFrame({
62
+ "start_date": ["2023-01-01", "2023-01-01"],
63
+ "end_date": ["2023-06-01", "2023-06-01"],
64
+ "geometry": encode_geometries([geometry, geometry]),
65
+ })
66
+ df = client.get.multiple_sits(gdf, satellite="Sentinel2", reducers=["mean", "std"])
67
+
68
+ # geopandas.GeoDataFrame also works (geopandas is optional — only
69
+ # imported if you actually pass one; it's not installed alongside the client)
70
+ import geopandas as gpd
71
+
72
+ gdf_geopandas = gpd.GeoDataFrame(
73
+ {"start_date": ["2023-01-01"], "end_date": ["2023-06-01"]},
74
+ geometry=[geometry],
75
+ crs="EPSG:4326",
76
+ )
77
+ df = client.get.multiple_sits(gdf_geopandas, satellite="Sentinel2")
78
+
79
+ # image — returns an in-memory zipfile.ZipFile, nothing decoded
80
+ zf = client.get.image(geometry, "2023-01-01", "2023-06-01", satellite="Sentinel2")
81
+ zf.extractall("out/")
82
+ ```
83
+
84
+ ## Quickstart — async
85
+
86
+ Same surface, `AsyncAgriGEEClient`:
87
+
88
+ ```python
89
+ import asyncio
90
+ from agrigee_lite_client import AsyncAgriGEEClient
91
+
92
+ async def main():
93
+ async with AsyncAgriGEEClient("http://192.168.3.204:8100") as client:
94
+ df = await client.get.sits(geometry, "2023-01-01", "2023-06-01", satellite="Sentinel2")
95
+
96
+ asyncio.run(main())
97
+ ```
98
+
99
+ ## Validation before anything leaves your machine
100
+
101
+ `sits()`/`multiple_sits()` check that the requested dates actually intersect
102
+ the satellite's operating period — the same check the server does — and
103
+ drop (with a warning) rows that don't, raising `ValueError` if none are
104
+ left. All of this happens **before** building the GeoParquet or opening a
105
+ connection, using a static table embedded in the client (no network call).
106
+ An unknown satellite name is also caught here (`AgriGEEUnknownSatelliteError`).
107
+
108
+ Since that table is a copy of what the server knows, the client checks that
109
+ it's running against the same server version (via `GET /version`) before
110
+ trusting it — a mismatch raises `AgriGEEVersionMismatchError` instead of
111
+ validating against possibly stale data.
112
+
113
+ ## What this client does NOT do (on purpose)
114
+
115
+ - No CRS reprojection — input geometries must be in WGS84 (EPSG:4326 /
116
+ OGC:CRS84). If you pass a `geopandas.GeoDataFrame` with a different CRS,
117
+ the client rejects it with a clear error instead of reprojecting.
118
+ - No local caching, no sophisticated retry/backoff — the cache already lives
119
+ on the server; the only "retry" here is `httpx`'s transport timeout and a
120
+ simple poll loop against `GET /jobs/{id}`.
121
+ - No image decoding — `get.image(...)` returns a raw `zipfile.ZipFile`, no
122
+ `numpy`/`tifffile`/`rasterio`.
123
+ - `get.sits(...)` isn't a separate fast path: it's sugar over
124
+ `get.multiple_sits(...)` with a single row, so it pays the job+poll+
125
+ download cost even for one geometry. See `SPECS.md §6`.
126
+
127
+ ## Versioning
128
+
129
+ This package's version always matches `agrigee_lite`'s (the server), same
130
+ commit — they aren't versioned independently. A test
131
+ (`tests/test_version_matches_server.py`) enforces this in CI.
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ cd agrigee_lite_client
137
+ pip install -e ".[dev]"
138
+ pytest
139
+ ruff check .
140
+ ```
141
+
142
+ `tests/test_client_against_live_api.py` only runs if `AGRIGEE_TEST_API_URL`
143
+ is set — the rest (`test_client_mocked.py`, `test_async_client_mocked.py`)
144
+ use `httpx.MockTransport` and need no network.
@@ -0,0 +1,13 @@
1
+ agrigee_lite_client/__init__.py,sha256=oEDUvBg-I73ld7_8u-1WU875GLe5Wyi_qnAYhKcfzpw,706
2
+ agrigee_lite_client/_async_client.py,sha256=igMn_lCOXt5vYi4inqJfs56h_oBy9uNmYcwGVmsNh30,7600
3
+ agrigee_lite_client/_client.py,sha256=1VkH8Xextw5ieGjrbFpPgH_USvh2PZ73MWsPCOLZjSU,7453
4
+ agrigee_lite_client/_exceptions.py,sha256=SQSZfzbFiEJusWCLI0AJG62cwLJkcgGRaG-jQ5eSrjY,2155
5
+ agrigee_lite_client/_geo_compat.py,sha256=xUBD0aiabpc7D0MCy2d61e2gTMvLQgJzOQnnDbANu6M,1803
6
+ agrigee_lite_client/_geoparquet.py,sha256=r86mnSNU2jcT7vjRc3p1M3qYxVIvOedHMlxIW3i-yTE,2601
7
+ agrigee_lite_client/_get.py,sha256=IqC8EdocfKOgXOplx8xBLUTt-w1sL9PIJieSJWuHHQw,3936
8
+ agrigee_lite_client/_satellite_dates.py,sha256=8Q_ptzrXr9fVPBNHdfFnjrGluAgAS0H1No5vgMExAu8,4326
9
+ agrigee_lite_client/_validation.py,sha256=MtkGH7rbCHNyoH0Co9C2sZnDx1i7SY1fJE2hFdSKdfc,3748
10
+ agrigee_lite_client/_version.py,sha256=VYwI6Af7wusgH04MJHQ-bTxKf7PXDZ6frugNVXU5_KA,456
11
+ agrigee_lite_client-3.4.1.dist-info/METADATA,sha256=3QsQE1q7DrfUHKdQOXC3p7y5bCtVVLRWyIkXHbCBB6c,5632
12
+ agrigee_lite_client-3.4.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
13
+ agrigee_lite_client-3.4.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any