cecil 0.1.3__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.

Potentially problematic release.


This version of cecil might be problematic. Click here for more details.

cecil/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .client import Client
2
+ from .errors import Error
3
+ from .version import __version__
cecil/client.py ADDED
@@ -0,0 +1,212 @@
1
+ import os
2
+ from typing import Dict, List, Optional
3
+
4
+ import pandas as pd
5
+ import requests
6
+
7
+ from pydantic import BaseModel
8
+ from requests import auth
9
+
10
+ import xarray
11
+ from .errors import (
12
+ Error,
13
+ _handle_bad_request,
14
+ _handle_method_not_allowed,
15
+ _handle_not_found,
16
+ _handle_too_many_requests,
17
+ _handle_unprocessable_entity,
18
+ )
19
+ from .models import (
20
+ AOI,
21
+ AOICreate,
22
+ OrganisationSettings,
23
+ RecoverAPIKey,
24
+ RecoverAPIKeyRequest,
25
+ RotateAPIKey,
26
+ RotateAPIKeyRequest,
27
+ User,
28
+ UserCreate,
29
+ SubscriptionParquetFiles,
30
+ SubscriptionListFiles,
31
+ Subscription,
32
+ SubscriptionCreate,
33
+ )
34
+ from .version import __version__
35
+ from .xarray import load_xarray
36
+
37
+
38
+ class Client:
39
+ def __init__(self, env: str = None) -> None:
40
+ self._api_auth = None
41
+ self._base_url = (
42
+ "https://api.cecil.earth" if env is None else f"https://{env}.cecil.earth"
43
+ )
44
+
45
+ def create_aoi(self, geometry: Dict, external_ref: str = "") -> AOI:
46
+ # TODO: validate geometry
47
+ res = self._post(
48
+ url="/v0/aois",
49
+ model=AOICreate(geometry=geometry, external_ref=external_ref),
50
+ )
51
+ return AOI(**res)
52
+
53
+ def get_aoi(self, id: str) -> AOI:
54
+ res = self._get(url=f"/v0/aois/{id}")
55
+ return AOI(**res)
56
+
57
+ def list_aois(self) -> List[AOI]:
58
+ res = self._get(url="/v0/aois")
59
+ return [AOI(**record) for record in res["records"]]
60
+
61
+ def list_subscriptions(self) -> List[Subscription]:
62
+ res = self._get(url="/v0/subscriptions")
63
+ return [Subscription(**record) for record in res["records"]]
64
+
65
+ def create_subscription(
66
+ self, aoi_id: str, dataset_id: str, external_ref: str = ""
67
+ ) -> Subscription:
68
+ res = self._post(
69
+ url="/v0/subscriptions",
70
+ model=SubscriptionCreate(
71
+ aoi_id=aoi_id, dataset_id=dataset_id, external_ref=external_ref
72
+ ),
73
+ )
74
+
75
+ return Subscription(**res)
76
+
77
+ def get_subscription(self, id: str) -> Subscription:
78
+ res = self._get(url=f"/v0/subscriptions/{id}")
79
+ return Subscription(**res)
80
+
81
+ def load_xarray(self, subscription_id: str) -> xarray.Dataset:
82
+ res = SubscriptionListFiles(
83
+ **self._get(url=f"/v0/subscriptions/{subscription_id}/files/tiff")
84
+ )
85
+ return load_xarray(res)
86
+
87
+ def load_dataframe(self, subscription_id: str) -> pd.DataFrame:
88
+ res = SubscriptionParquetFiles(
89
+ **self._get(url=f"/v0/subscriptions/{subscription_id}/parquet-files")
90
+ )
91
+
92
+ if not res.files:
93
+ return pd.DataFrame()
94
+
95
+ return pd.concat((pd.read_parquet(f) for f in res.files))
96
+
97
+ def recover_api_key(self, email: str) -> RecoverAPIKey:
98
+ res = self._post(
99
+ url="/v0/api-key/recover",
100
+ model=RecoverAPIKeyRequest(email=email),
101
+ skip_auth=True,
102
+ )
103
+
104
+ return RecoverAPIKey(**res)
105
+
106
+ def rotate_api_key(self) -> RotateAPIKey:
107
+ res = self._post(url=f"/v0/api-key/rotate", model=RotateAPIKeyRequest())
108
+
109
+ return RotateAPIKey(**res)
110
+
111
+ def create_user(self, first_name: str, last_name: str, email: str) -> User:
112
+ res = self._post(
113
+ url="/v0/users",
114
+ model=UserCreate(
115
+ first_name=first_name,
116
+ last_name=last_name,
117
+ email=email,
118
+ ),
119
+ )
120
+ return User(**res)
121
+
122
+ def get_user(self, id: str) -> User:
123
+ res = self._get(url=f"/v0/users/{id}")
124
+ return User(**res)
125
+
126
+ def list_users(self) -> List[User]:
127
+ res = self._get(url="/v0/users")
128
+ return [User(**record) for record in res["records"]]
129
+
130
+ def get_organisation_settings(self) -> OrganisationSettings:
131
+ res = self._get(url="/v0/organisation/settings")
132
+ return OrganisationSettings(**res)
133
+
134
+ def update_organisation_settings(
135
+ self,
136
+ *,
137
+ monthly_subscription_limit,
138
+ ) -> OrganisationSettings:
139
+ res = self._post(
140
+ url="/v0/organisation/settings",
141
+ model=OrganisationSettings(
142
+ monthly_subscription_limit=monthly_subscription_limit,
143
+ ),
144
+ )
145
+ return OrganisationSettings(**res)
146
+
147
+ def _request(self, method: str, url: str, skip_auth=False, **kwargs) -> Dict:
148
+
149
+ if not skip_auth:
150
+ self._set_auth()
151
+
152
+ headers = {"cecil-python-sdk-version": __version__}
153
+
154
+ try:
155
+ r = requests.request(
156
+ method=method,
157
+ url=self._base_url + url,
158
+ auth=self._api_auth,
159
+ headers=headers,
160
+ timeout=None,
161
+ **kwargs,
162
+ )
163
+ r.raise_for_status()
164
+ return r.json()
165
+
166
+ except requests.exceptions.ConnectionError:
167
+ raise Error("failed to connect to the Cecil Platform")
168
+
169
+ except requests.exceptions.HTTPError as err:
170
+ message = f"Request failed with status code {err.response.status_code}"
171
+ if err.response.text != "":
172
+ message += f": {err.response.text}"
173
+
174
+ match err.response.status_code:
175
+ case 400:
176
+ _handle_bad_request(err.response)
177
+ case 401:
178
+ raise Error("unauthorised")
179
+ case 404:
180
+ _handle_not_found(err.response)
181
+ case 405:
182
+ _handle_method_not_allowed(err.response)
183
+ case 422:
184
+ _handle_unprocessable_entity(err.response)
185
+ case 429:
186
+ _handle_too_many_requests(err.response)
187
+ case 500:
188
+ raise Error("internal server error")
189
+ case _:
190
+ raise Error(
191
+ f"request failed with code {err.response.status_code}",
192
+ err.response.text,
193
+ )
194
+
195
+ def _get(self, url: str, **kwargs) -> Dict:
196
+ return self._request(method="get", url=url, **kwargs)
197
+
198
+ def _post(self, url: str, model: BaseModel, skip_auth=False, **kwargs) -> Dict:
199
+ return self._request(
200
+ method="post",
201
+ url=url,
202
+ json=model.model_dump(by_alias=True),
203
+ skip_auth=skip_auth,
204
+ **kwargs,
205
+ )
206
+
207
+ def _set_auth(self) -> None:
208
+ try:
209
+ api_key = os.environ["CECIL_API_KEY"]
210
+ self._api_auth = auth.HTTPBasicAuth(username=api_key, password="")
211
+ except KeyError:
212
+ raise ValueError("environment variable CECIL_API_KEY not set") from None
cecil/errors.py ADDED
@@ -0,0 +1,85 @@
1
+ import json
2
+
3
+
4
+ class Error(Exception):
5
+ def __init__(self, message: str, details=None):
6
+ self.message = message
7
+ self.details = details
8
+
9
+ if self.details is not None:
10
+ super().__init__(f"{self.message} \n{json.dumps(self.details, indent=2)}")
11
+ return
12
+
13
+ super().__init__(self.message)
14
+
15
+
16
+ def _format_json_key(value: str):
17
+ return "".join(["_" + i.lower() if i.isupper() else i for i in value]).lstrip("_")
18
+
19
+
20
+ def _is_json(value: str):
21
+ try:
22
+ json.loads(value)
23
+ return True
24
+ except ValueError:
25
+ return False
26
+
27
+
28
+ def _handle_bad_request(response):
29
+ if not _is_json(response.text):
30
+ raise Error("bad request")
31
+
32
+ details = {}
33
+ for key, value in response.json().items():
34
+ details[_format_json_key(key)] = value
35
+
36
+ raise Error("bad request", details)
37
+
38
+
39
+ def _handle_method_not_allowed(response):
40
+ if not _is_json(response.text):
41
+ raise Error("method not allowed")
42
+
43
+ details = {}
44
+ for key, value in response.json().items():
45
+ details[_format_json_key(key)] = value
46
+
47
+ raise Error("method not allowed", details)
48
+
49
+
50
+ def _handle_not_found(response):
51
+ if not _is_json(response.text):
52
+ raise Error("resource not found")
53
+
54
+ details = {}
55
+ for key, value in response.json().items():
56
+ details[_format_json_key(key)] = value
57
+
58
+ raise Error("resource not found", details)
59
+
60
+
61
+ def _handle_too_many_requests(response):
62
+ if not _is_json(response.text):
63
+ raise Error("too many requests")
64
+
65
+ details = {}
66
+
67
+ for key, value in response.json().items():
68
+ details[_format_json_key(key)] = value
69
+
70
+ raise Error("too many requests", details)
71
+
72
+
73
+ def _handle_unprocessable_entity(response):
74
+ if not _is_json(response.text):
75
+ raise Error(f"failed to process request")
76
+
77
+ res_body = response.json()
78
+ if "params" not in res_body:
79
+ raise Error(f"failed to process request")
80
+
81
+ details = {}
82
+ for key, value in res_body["params"].items():
83
+ details[_format_json_key(key)] = value
84
+
85
+ raise Error(f"failed to process request", details)
cecil/models.py ADDED
@@ -0,0 +1,122 @@
1
+ import datetime
2
+ from typing import Dict, Optional, List
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+ from pydantic.alias_generators import to_camel
6
+
7
+
8
+ class AOI(BaseModel):
9
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
10
+ id: str
11
+ external_ref: str
12
+ geometry: Optional[Dict] = None
13
+ hectares: float
14
+ created_at: datetime.datetime
15
+ created_by: str
16
+
17
+ class AOICreate(BaseModel):
18
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
19
+ geometry: Dict
20
+ external_ref: str
21
+
22
+
23
+ class OrganisationSettings(BaseModel):
24
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
25
+ monthly_subscription_limit: Optional[int]
26
+
27
+
28
+ class RecoverAPIKey(BaseModel):
29
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
30
+ message: str
31
+
32
+
33
+ class RecoverAPIKeyRequest(BaseModel):
34
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
35
+ email: str
36
+
37
+
38
+ class RotateAPIKey(BaseModel):
39
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
40
+ new_api_key: str
41
+
42
+
43
+ class RotateAPIKeyRequest(BaseModel):
44
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
45
+
46
+
47
+ class User(BaseModel):
48
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
49
+ id: str
50
+ first_name: str
51
+ last_name: str
52
+ email: str
53
+ created_at: datetime.datetime
54
+ created_by: str
55
+
56
+
57
+ class UserCreate(BaseModel):
58
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
59
+ first_name: str
60
+ last_name: str
61
+ email: str
62
+
63
+
64
+ class Bucket(BaseModel):
65
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
66
+ name: str
67
+ prefix: str
68
+
69
+
70
+ class BucketCredentials(BaseModel):
71
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
72
+ access_key_id: str
73
+ secret_access_key: str
74
+ session_token: str
75
+ region: str
76
+ expiration: datetime.datetime
77
+
78
+
79
+ class Band(BaseModel):
80
+ number: int
81
+ name: str
82
+ dtype: str
83
+ nodata: Optional[float | int] = None
84
+
85
+
86
+ class File(BaseModel):
87
+ bands: List[Band]
88
+
89
+
90
+ class SubscriptionListFiles(BaseModel):
91
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
92
+ provider_name: str
93
+ dataset_id: str
94
+ dataset_name: str
95
+ aoi_id: str
96
+ subscription_id: str
97
+ bucket: Bucket
98
+ credentials: BucketCredentials
99
+ allowed_actions: List
100
+ file_mapping: Dict[str, File]
101
+
102
+
103
+ class SubscriptionParquetFiles(BaseModel):
104
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
105
+ files: List[str]
106
+
107
+
108
+ class Subscription(BaseModel):
109
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
110
+ id: str
111
+ aoi_id: str
112
+ dataset_id: str
113
+ external_ref: str
114
+ created_at: datetime.datetime
115
+ created_by: str
116
+
117
+
118
+ class SubscriptionCreate(BaseModel):
119
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
120
+ aoi_id: str
121
+ dataset_id: str
122
+ external_ref: str
cecil/version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.3"
cecil/xarray.py ADDED
@@ -0,0 +1,131 @@
1
+ import re
2
+ from datetime import datetime
3
+
4
+ import boto3
5
+ import dask
6
+ import rasterio
7
+ import rasterio.session
8
+ import rioxarray
9
+ import xarray
10
+ import numpy as np
11
+
12
+ from .models import SubscriptionListFiles
13
+
14
+
15
+ def load_xarray(res: SubscriptionListFiles) -> xarray.Dataset:
16
+ session = boto3.session.Session(
17
+ aws_access_key_id=res.credentials.access_key_id,
18
+ aws_secret_access_key=res.credentials.secret_access_key,
19
+ aws_session_token=res.credentials.session_token,
20
+ region_name=res.credentials.region,
21
+ )
22
+
23
+ keys = _list_keys(session, res.bucket.name, res.bucket.prefix)
24
+
25
+ if not keys:
26
+ return xarray.Dataset()
27
+
28
+ timestamp_pattern = re.compile(r"\d{4}/\d{2}/\d{2}/\d{2}/\d{2}/\d{2}")
29
+ data_vars = {}
30
+
31
+ with rasterio.env.Env(
32
+ session=rasterio.session.AWSSession(session),
33
+ ):
34
+ first_file = rioxarray.open_rasterio(
35
+ f"s3://{res.bucket.name}/{keys[0]}", chunks="auto"
36
+ )
37
+
38
+ for key in keys:
39
+ filename = key.split("/")[-1]
40
+
41
+ file_info = res.file_mapping.get(filename)
42
+ if not file_info:
43
+ continue
44
+
45
+ timestamp_str = timestamp_pattern.search(key).group()
46
+
47
+ for band_info in file_info.bands:
48
+ lazy_array = dask.array.from_delayed(
49
+ dask.delayed(_load_file)(
50
+ session, f"s3://{res.bucket.name}/{key}", band_info.number
51
+ ),
52
+ shape=(
53
+ first_file.rio.height,
54
+ first_file.rio.width,
55
+ ),
56
+ dtype=band_info.dtype,
57
+ )
58
+
59
+ nodata = band_info.nodata if band_info.nodata is not None else np.nan
60
+
61
+ band_da = xarray.DataArray(
62
+ lazy_array,
63
+ dims=("y", "x"),
64
+ coords={
65
+ "y": first_file.y.values,
66
+ "x": first_file.x.values,
67
+ },
68
+ attrs={
69
+ "AREA_OR_POINT": first_file.attrs["AREA_OR_POINT"],
70
+ "_FillValue": np.dtype(band_info.dtype).type(nodata),
71
+ "scale_factor": first_file.attrs["scale_factor"],
72
+ "add_offset": first_file.attrs["add_offset"],
73
+ },
74
+ )
75
+ # band_da.encoding = first_file.encoding.copy() # TODO: is it the same for all files?
76
+ band_da.rio.write_crs(first_file.rio.crs, inplace=True)
77
+ band_da.rio.write_transform(first_file.rio.transform(), inplace=True)
78
+
79
+ band_da.name = band_info.name
80
+
81
+ # Dataset with time dimension
82
+ if timestamp_str != "0000/00/00/00/00/00":
83
+ t = datetime.strptime(timestamp_str, "%Y/%m/%d/%H/%M/%S")
84
+ band_da = band_da.expand_dims("time")
85
+ band_da = band_da.assign_coords(time=[t])
86
+
87
+ if band_info.name not in data_vars:
88
+ data_vars[band_info.name] = []
89
+
90
+ data_vars[band_info.name].append(band_da)
91
+
92
+ for var_name, time_series in data_vars.items():
93
+ if "time" in time_series[0].dims:
94
+ data_vars[var_name] = xarray.concat(time_series, dim="time", join="exact")
95
+ else:
96
+ data_vars[var_name] = time_series[0]
97
+
98
+ return xarray.Dataset(
99
+ data_vars=data_vars,
100
+ attrs={
101
+ "provider_name": res.provider_name,
102
+ "dataset_name": res.dataset_name,
103
+ "dataset_id": res.dataset_id,
104
+ "aoi_id": res.aoi_id,
105
+ "subscription_id": res.subscription_id,
106
+ },
107
+ )
108
+
109
+
110
+ def _load_file(aws_session: boto3.session.Session, url: str, band_num: int):
111
+ with rasterio.env.Env(
112
+ session=rasterio.session.AWSSession(aws_session),
113
+ ):
114
+ with rasterio.open(url) as src:
115
+ return src.read(band_num)
116
+
117
+
118
+ def _list_keys(session: boto3.session.Session, bucket_name, prefix) -> list[str]:
119
+ s3_client = session.client("s3")
120
+ paginator = s3_client.get_paginator("list_objects_v2")
121
+ page_iterator = paginator.paginate(
122
+ Bucket=bucket_name,
123
+ Prefix=prefix,
124
+ )
125
+
126
+ keys = []
127
+ for page in page_iterator:
128
+ for obj in page.get("Contents", []):
129
+ keys.append(obj["Key"])
130
+
131
+ return keys
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: cecil
3
+ Version: 0.1.3
4
+ Summary: Python SDK for Cecil Earth
5
+ License-Expression: MIT
6
+ License-File: LICENSE.txt
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: boto3<2.0.0,>=1.42.0
13
+ Requires-Dist: dask>=2025.9.1
14
+ Requires-Dist: pyarrow<23.0.0,>=22.0.0
15
+ Requires-Dist: pydantic<3.0.0,>=2.11.9
16
+ Requires-Dist: requests<3.0.0,>=2.32.5
17
+ Requires-Dist: rioxarray<1.0.0,>=0.19.0
18
+ Requires-Dist: xarray>=2025.6.1
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Cecil SDK
22
+
23
+ Please refer to the Cecil documentation:
24
+
25
+ https://docs.cecil.earth
@@ -0,0 +1,10 @@
1
+ cecil/__init__.py,sha256=AEcRl73BDSAQe6W0d1PDD87IEcumARtREl7dCVa_YQY,86
2
+ cecil/client.py,sha256=qv4Pm3EF3Q-TXBkcD_njYjrCipvgNYhA_tYyut_p5rs,6593
3
+ cecil/errors.py,sha256=EnyYvFfU_JWYTTRax58bdwOndri2f-HzbqyzxtoV8uo,2100
4
+ cecil/models.py,sha256=KpeIjkrQ_9L_84F8wA837pn0tpBopDRT-9jRuBnTAHA,3053
5
+ cecil/version.py,sha256=XEqb2aiIn8fzGE68Mph4ck1FtQqsR_am0wRWvrYPffQ,22
6
+ cecil/xarray.py,sha256=OIYL8PPOA7ZxiJZA6eQLhJi9WvZmdokf6u-_BfPwwiE,4200
7
+ cecil-0.1.3.dist-info/METADATA,sha256=AYJWa9PwH5osacVpBaMH4XXLjWviaij68P5j9c-m_0A,724
8
+ cecil-0.1.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
9
+ cecil-0.1.3.dist-info/licenses/LICENSE.txt,sha256=mUexcmfYx3bG1VIzAdQTOf_NzStYw6-QkKVdUY_d4i4,1066
10
+ cecil-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 cecilearth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.