teledetection 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,21 @@
1
+ """Teledetection SDK module."""
2
+
3
+ # flake8: noqa
4
+
5
+ from importlib.metadata import version, PackageNotFoundError
6
+ from teledetection.sdk.signing import (
7
+ sign,
8
+ sign_inplace,
9
+ sign_urls,
10
+ sign_item,
11
+ sign_asset,
12
+ sign_item_collection,
13
+ sign_url_put,
14
+ ) # noqa
15
+ from .sdk.oauth2 import OAuth2Session # noqa
16
+ from .sdk.http import get_headers, get_userinfo, get_username
17
+
18
+ try:
19
+ __version__ = version("teledetection")
20
+ except PackageNotFoundError:
21
+ pass
teledetection/cli.py ADDED
@@ -0,0 +1,364 @@
1
+ """Teledetection package Command Line Interface."""
2
+
3
+ import os
4
+ import tempfile
5
+ import subprocess
6
+ import getpass
7
+ from typing import Dict, List
8
+ import datetime
9
+ import click
10
+
11
+ from .sdk.logger import get_logger_for
12
+
13
+ from .sdk.model import ApiKey
14
+ from .sdk.http import OAuth2ConnectionMethod
15
+ from .sdk.utils import create_session
16
+
17
+
18
+ @click.group(
19
+ help="Teledetection CLI",
20
+ context_settings={
21
+ "help_option_names": ["-h", "--help"],
22
+ "max_content_width": 120,
23
+ },
24
+ )
25
+ def tld() -> None:
26
+ """Teledetection Command Line Interface."""
27
+
28
+
29
+ log = get_logger_for(__name__)
30
+ conn = OAuth2ConnectionMethod()
31
+
32
+
33
+ def _http(route: str, params: dict | None = None):
34
+ """Perform an HTTP request."""
35
+ session = create_session()
36
+ ret = session.get(
37
+ f"{conn.endpoint}{route}",
38
+ timeout=5,
39
+ params=params,
40
+ headers=conn.get_headers(),
41
+ )
42
+ ret.raise_for_status()
43
+ return ret
44
+
45
+
46
+ def _get_all_keys() -> List[str]:
47
+ """Retrieve all API keys."""
48
+ return _http("list_api_keys_with_metadata").json()
49
+
50
+
51
+ def _create_new_key(description: str) -> Dict[str, str]:
52
+ """Create a new API key."""
53
+
54
+ def _default_desc():
55
+ """Default description."""
56
+ return (
57
+ f"Created by {getpass.getuser()} on "
58
+ f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}"
59
+ )
60
+
61
+ return _http(
62
+ "create_api_key", params={"description": description or _default_desc()}
63
+ ).json()
64
+
65
+
66
+ def do_create_key(description: str):
67
+ """Create a new API key."""
68
+ log.info("New API key %s created", _create_new_key(description=description))
69
+
70
+
71
+ def do_list_keys():
72
+ """List all generated API keys."""
73
+ keys = _get_all_keys()
74
+ if keys:
75
+ log.info("All existing API keys:")
76
+ log.info("Creation date \tAccess key \t[Description]")
77
+ # Prints: 2025-05-06 09:22:50 zPL9GaQrokbMCQGe
78
+ for key in keys:
79
+ log.info(
80
+ "%s\t%s\t%s",
81
+ key["created"].split(".")[0],
82
+ key["access-key"],
83
+ key["description"],
84
+ )
85
+ else:
86
+ log.info("No API key found.")
87
+
88
+
89
+ def do_revoke_key(access_key: str):
90
+ """Revoke an API key."""
91
+ _http(f"revoke_api_key?access_key={access_key}")
92
+ log.info(f"API key {access_key} revoked")
93
+
94
+
95
+ def do_revoke_all_keys():
96
+ """Revoke all API keys."""
97
+ keys = _get_all_keys()
98
+ for key in keys:
99
+ do_revoke_key(key["access-key"])
100
+ if not keys:
101
+ log.info("No API key to revoke.")
102
+
103
+
104
+ def do_register_key(description: str):
105
+ """Create and store a new API key."""
106
+ new_key = _create_new_key(description=description)
107
+ ApiKey.from_dict(new_key).to_config_dir()
108
+ log.info("New API key %s created and stored in config directory", new_key)
109
+
110
+
111
+ def do_remove_key(dont_revoke: bool):
112
+ """Delete the stored API key."""
113
+ if not dont_revoke:
114
+ do_revoke_key(ApiKey.from_config_dir().access_key)
115
+ ApiKey.delete_from_config_dir()
116
+
117
+
118
+ API_KEY_OPS = {
119
+ "create": lambda arg: do_create_key(description=arg),
120
+ "revoke": lambda arg: do_revoke_key(access_key=arg),
121
+ "revoke-all": lambda arg: do_revoke_all_keys(),
122
+ "register": lambda arg: do_register_key(description=arg),
123
+ "remove": lambda arg: do_remove_key(dont_revoke=any(arg)),
124
+ "list": lambda arg: do_list_keys(),
125
+ }
126
+
127
+
128
+ @tld.command()
129
+ @click.argument(
130
+ "operation",
131
+ type=click.Choice(list(API_KEY_OPS.keys()), case_sensitive=False),
132
+ required=False,
133
+ )
134
+ @click.argument("argument", default="")
135
+ @click.pass_context
136
+ def apikey(ctx, operation: str, argument: str):
137
+ """Manage API keys.
138
+
139
+ \b
140
+ Other operations:
141
+ list : List all available API keys
142
+
143
+ \b
144
+ Operations on locally-stored key:
145
+ register : Register a new key
146
+ remove : Remove a key provided in argument
147
+
148
+ \b
149
+ Manually input keys:
150
+ create : Create a new API key
151
+ revoke : Revoke a specific key
152
+ revoke-all : Revoke all keys
153
+ """
154
+ if not operation:
155
+ click.echo(ctx.get_help())
156
+ ctx.exit(0)
157
+ API_KEY_OPS[operation](argument)
158
+
159
+
160
+ try:
161
+ from .upload import diff
162
+ from .upload.stac import (
163
+ StacTransactionsHandler,
164
+ StacUploadTransactionsHandler,
165
+ DEFAULT_S3_EP,
166
+ DEFAULT_STAC_EP,
167
+ DEFAULT_S3_STORAGE,
168
+ )
169
+
170
+ @tld.command()
171
+ @click.argument("stac_obj_path")
172
+ @click.option(
173
+ "--stac_endpoint",
174
+ help="Endpoint to which STAC objects will be sent",
175
+ type=str,
176
+ default=DEFAULT_STAC_EP,
177
+ )
178
+ @click.option(
179
+ "--storage_endpoint",
180
+ type=str,
181
+ help="Storage endpoint assets will be sent to",
182
+ default=DEFAULT_S3_EP,
183
+ )
184
+ @click.option(
185
+ "-b",
186
+ "--storage_bucket",
187
+ help="Storage bucket assets will be sent to",
188
+ type=str,
189
+ default=DEFAULT_S3_STORAGE,
190
+ )
191
+ @click.option(
192
+ "-o",
193
+ "--overwrite",
194
+ is_flag=True,
195
+ default=False,
196
+ help="Overwrite assets if already existing",
197
+ )
198
+ @click.option(
199
+ "--keep_cog_dir",
200
+ help="Set a directory to keep converted COG files",
201
+ type=str,
202
+ nargs=1,
203
+ default="",
204
+ )
205
+ def publish(
206
+ stac_obj_path: str,
207
+ stac_endpoint: str,
208
+ storage_endpoint: str,
209
+ storage_bucket: str,
210
+ overwrite: bool,
211
+ keep_cog_dir: str,
212
+ ):
213
+ """Publish a STAC object (collection or item collection)."""
214
+ StacUploadTransactionsHandler(
215
+ stac_endpoint=stac_endpoint,
216
+ sign=False,
217
+ storage_endpoint=storage_endpoint,
218
+ storage_bucket=storage_bucket,
219
+ assets_overwrite=overwrite,
220
+ keep_cog_dir=keep_cog_dir,
221
+ ).load_and_publish(stac_obj_path)
222
+
223
+ @tld.command()
224
+ @click.option(
225
+ "--stac_endpoint",
226
+ help="Endpoint to which STAC objects will be sent",
227
+ type=str,
228
+ default=DEFAULT_STAC_EP,
229
+ )
230
+ @click.option("-c", "--col_id", type=str, help="STAC collection ID", required=True)
231
+ @click.option("-i", "--item_id", type=str, default=None, help="STAC item ID")
232
+ @click.option("-s", "--sign", is_flag=True, default=False, help="Sign assets HREFs")
233
+ @click.option(
234
+ "-p", "--pretty", is_flag=True, default=False, help="Pretty indent JSON"
235
+ )
236
+ @click.option("-o", "--out_json", type=str, help="Output .json file", required=True)
237
+ def grab( # pylint: disable=too-many-arguments,too-many-positional-arguments
238
+ stac_endpoint: str,
239
+ col_id: str,
240
+ item_id: str,
241
+ sign: bool,
242
+ pretty: bool,
243
+ out_json: str,
244
+ ):
245
+ """Grab a STAC object (collection, or item) and save it as .json."""
246
+ StacTransactionsHandler(stac_endpoint=stac_endpoint, sign=sign).load_and_save(
247
+ col_id=col_id, obj_pth=out_json, item_id=item_id, pretty=pretty
248
+ )
249
+
250
+ @tld.command()
251
+ @click.option(
252
+ "--stac_endpoint",
253
+ help="Endpoint to which STAC objects will be sent",
254
+ type=str,
255
+ default=DEFAULT_STAC_EP,
256
+ )
257
+ @click.option("-c", "--col_id", type=str, help="STAC collection ID", required=True)
258
+ @click.option("-i", "--item_id", type=str, default=None, help="STAC item ID")
259
+ def edit(stac_endpoint: str, col_id: str, item_id: str):
260
+ """Edit a STAC object (collection, or item)."""
261
+ with tempfile.NamedTemporaryFile(suffix=".json") as tf:
262
+ StacTransactionsHandler(
263
+ stac_endpoint=stac_endpoint, sign=False
264
+ ).load_and_save(
265
+ col_id=col_id, obj_pth=tf.name, item_id=item_id, pretty=True
266
+ )
267
+ editor = os.environ.get("EDITOR") or "vi"
268
+ subprocess.run([editor, tf.name], check=False)
269
+ StacTransactionsHandler(
270
+ stac_endpoint=stac_endpoint, sign=False
271
+ ).load_and_publish(obj_pth=tf.name)
272
+
273
+ @tld.command()
274
+ @click.option(
275
+ "--stac_endpoint",
276
+ help="Endpoint to which STAC objects will be sent",
277
+ type=str,
278
+ default=DEFAULT_STAC_EP,
279
+ )
280
+ @click.option("-c", "--col_id", type=str, help="STAC collection ID", required=True)
281
+ @click.option("-i", "--item_id", type=str, default=None, help="STAC item ID")
282
+ def delete(
283
+ stac_endpoint: str,
284
+ col_id: str,
285
+ item_id: str,
286
+ ):
287
+ """Delete a STAC object (collection or item)."""
288
+ StacTransactionsHandler(
289
+ stac_endpoint=stac_endpoint, sign=False
290
+ ).delete_item_or_col(col_id=col_id, item_id=item_id)
291
+
292
+ @tld.command()
293
+ @click.option(
294
+ "--stac_endpoint",
295
+ help="Endpoint to which STAC objects will be sent",
296
+ type=str,
297
+ default=DEFAULT_STAC_EP,
298
+ )
299
+ def list_cols(
300
+ stac_endpoint: str,
301
+ ):
302
+ """List collections."""
303
+ cols = list(
304
+ StacTransactionsHandler(
305
+ stac_endpoint=stac_endpoint, sign=False
306
+ ).client.get_collections()
307
+ )
308
+ print(f"Found {len(cols)} collection(s):")
309
+ for col in sorted(cols, key=lambda x: x.id):
310
+ print(f"\t{col.id}")
311
+
312
+ @tld.command()
313
+ @click.option(
314
+ "--stac_endpoint",
315
+ help="Endpoint to which STAC objects will be sent",
316
+ type=str,
317
+ default=DEFAULT_STAC_EP,
318
+ )
319
+ @click.option("-c", "--col_id", type=str, help="STAC collection ID", required=True)
320
+ @click.option(
321
+ "-m", "--max_items", type=int, help="Max number of items to display", default=20
322
+ )
323
+ @click.option("-s", "--sign", is_flag=True, default=False, help="Sign assets HREFs")
324
+ def list_col_items(stac_endpoint: str, col_id: str, max_items: int, sign: bool):
325
+ """List collection items."""
326
+ items = StacTransactionsHandler(
327
+ stac_endpoint=stac_endpoint, sign=sign
328
+ ).get_items(col_id=col_id, max_items=max_items)
329
+ print(f"Found {len(items)} item(s):")
330
+ for item in items:
331
+ print(f"\t{item.id}")
332
+
333
+ @tld.command()
334
+ @click.option(
335
+ "--stac_endpoint",
336
+ help="Endpoint to which STAC objects will be sent",
337
+ type=str,
338
+ default=DEFAULT_STAC_EP,
339
+ )
340
+ @click.option(
341
+ "-p", "--col_path", type=str, help="Local collection path", required=True
342
+ )
343
+ @click.option(
344
+ "-r",
345
+ "--remote_id",
346
+ type=str,
347
+ help="Remote collection ID. If not specified, will use local collection ID",
348
+ required=False,
349
+ )
350
+ def collection_diff(
351
+ stac_endpoint: str,
352
+ col_path: str,
353
+ remote_id: str = "",
354
+ ):
355
+ """List collection items."""
356
+ diff.compare_local_and_upstream(
357
+ StacTransactionsHandler(stac_endpoint=stac_endpoint, sign=False),
358
+ col_path,
359
+ remote_id,
360
+ )
361
+ except ImportError:
362
+ log.info(
363
+ "Running CLI without upload support. To install it, use `pip install teledetection[upload]`"
364
+ )
@@ -0,0 +1 @@
1
+ """SDK module."""
@@ -0,0 +1,124 @@
1
+ """HTTP connections with various methods."""
2
+
3
+ from typing import Dict, Any
4
+ from ast import literal_eval
5
+ from pydantic import BaseModel, ConfigDict
6
+ from .logger import get_logger_for
7
+ from .utils import create_session
8
+ from .oauth2 import OAuth2Session, retrieve_token_endpoint
9
+ from .model import ApiKey
10
+ from .settings import ENV
11
+
12
+
13
+ log = get_logger_for(__name__)
14
+
15
+
16
+ class BareConnectionMethod(BaseModel):
17
+ """Bare connection method, no extra headers."""
18
+
19
+ model_config = ConfigDict(arbitrary_types_allowed=True)
20
+ endpoint: str = ENV.tld_signing_endpoint
21
+
22
+ def get_headers(self) -> Dict[str, str]:
23
+ """Get the headers."""
24
+ return {}
25
+
26
+
27
+ class OAuth2ConnectionMethod(BareConnectionMethod):
28
+ """OAuth2 connection method."""
29
+
30
+ oauth2_session: OAuth2Session = OAuth2Session()
31
+
32
+ def get_headers(self):
33
+ """Return the headers."""
34
+ return {"authorization": f"bearer {self.oauth2_session.get_access_token()}"}
35
+
36
+ def get_userinfo(self):
37
+ """Override parent method from BareConnectionMethod."""
38
+ openapi_url = retrieve_token_endpoint().replace("/token", "/userinfo")
39
+ return (
40
+ create_session()
41
+ .get(openapi_url, timeout=10, headers=self.get_headers())
42
+ .json()
43
+ )
44
+
45
+
46
+ class ApiKeyConnectionMethod(BareConnectionMethod):
47
+ """API key connection method."""
48
+
49
+ api_key: ApiKey
50
+
51
+ def get_headers(self):
52
+ """Return the headers."""
53
+ return self.api_key.to_dict()
54
+
55
+
56
+ class HTTPSession:
57
+ """HTTP session class."""
58
+
59
+ def __init__(self, timeout=10):
60
+ """Initialize the HTTP session."""
61
+ self.session = create_session()
62
+ self.timeout = timeout
63
+ self.headers = {
64
+ "Content-Type": "application/json",
65
+ "Accept": "application/json",
66
+ }
67
+ self._method = None
68
+
69
+ def get_method(self):
70
+ """Get method."""
71
+ log.debug("Get method")
72
+ if not self._method:
73
+ # Lazy instantiation
74
+ self.prepare_connection_method()
75
+ return self._method
76
+
77
+ def prepare_connection_method(self):
78
+ """Set the connection method."""
79
+ # Custom server without authentication method
80
+ if ENV.tld_disable_auth:
81
+ self._method = BareConnectionMethod(endpoint=ENV.tld_signing_endpoint)
82
+
83
+ # API key method
84
+ elif api_key := ApiKey.grab():
85
+ self._method = ApiKeyConnectionMethod(api_key=api_key)
86
+
87
+ # OAuth2 method
88
+ else:
89
+ self._method = OAuth2ConnectionMethod()
90
+
91
+ def post(self, route: str, params: Dict):
92
+ """Perform a POST request."""
93
+ method = self.get_method()
94
+ url = f"{method.endpoint}{route}"
95
+ headers = {**self.headers, **method.get_headers()}
96
+ log.debug("POST to %s", url)
97
+ response = self.session.post(url, json=params, headers=headers, timeout=10)
98
+ try:
99
+ response.raise_for_status()
100
+ except Exception as e:
101
+ log.error(literal_eval(response.text))
102
+ raise e
103
+
104
+ return response
105
+
106
+
107
+ session = HTTPSession()
108
+
109
+
110
+ def get_headers() -> dict[str, Any]:
111
+ """Return the headers needed to authenticate on the system."""
112
+ return session.get_method().get_headers()
113
+
114
+
115
+ def get_userinfo() -> dict[str, str]:
116
+ """Return userinfo."""
117
+ return OAuth2ConnectionMethod().get_userinfo()
118
+
119
+
120
+ def get_username() -> str:
121
+ """Return username."""
122
+ user_info = get_userinfo()
123
+ assert user_info, "Could not fetch user info"
124
+ return user_info["preferred_username"]
@@ -0,0 +1,15 @@
1
+ """Logger module."""
2
+
3
+ import os
4
+ import logging
5
+
6
+ # Logger
7
+ LOGLEVEL = os.environ.get("LOGLEVEL") or "INFO"
8
+ logging.basicConfig(level=LOGLEVEL)
9
+
10
+
11
+ def get_logger_for(name: str):
12
+ """Get logger for a named module."""
13
+ logger = logging.getLogger(name)
14
+ logger.setLevel(level=LOGLEVEL)
15
+ return logger
@@ -0,0 +1,119 @@
1
+ """Models."""
2
+
3
+ import os
4
+ import json
5
+ from typing import Dict
6
+ from pydantic import BaseModel, Field, ConfigDict # pylint: disable = no-name-in-module
7
+ from .logger import get_logger_for
8
+ from .settings import Settings, get_config_path
9
+
10
+ log = get_logger_for(__name__)
11
+
12
+
13
+ class Serializable(BaseModel): # pylint: disable = R0903
14
+ """Base class for serializable pyantic models."""
15
+
16
+ model_config = ConfigDict(
17
+ populate_by_name=True,
18
+ )
19
+
20
+ @classmethod
21
+ def get_cfg_file_name(cls) -> str | None:
22
+ """Get the config file name (without full path)."""
23
+ name = f".{cls.__name__.lower()}"
24
+ log.debug("Looking for config file for %s", name)
25
+ cfg_pth = get_config_path()
26
+ cfg_file = os.path.join(cfg_pth, name) if cfg_pth else None
27
+ log.debug("Config file %sfound %s", "" if cfg_file else "not ", cfg_file or "")
28
+ return cfg_file
29
+
30
+ @classmethod
31
+ def from_config_dir(cls):
32
+ """Try to load from config directory."""
33
+ cfg_file = cls.get_cfg_file_name()
34
+ return cls.from_file(cfg_file) if cfg_file else None
35
+
36
+ def to_config_dir(self):
37
+ """Try to save to config files."""
38
+ cfg_file = self.get_cfg_file_name()
39
+ if cfg_file:
40
+ self.to_file(cfg_file)
41
+
42
+ @classmethod
43
+ def from_dict(cls, dict: Dict):
44
+ """Get the object from dict."""
45
+ return cls(**dict)
46
+
47
+ def to_dict(self) -> Dict[str, str]:
48
+ """To dict."""
49
+ return self.model_dump(by_alias=True)
50
+
51
+ @classmethod
52
+ def from_file(cls, file_path: str):
53
+ """Load object from a file."""
54
+ try:
55
+ log.debug("Reading JSON file %s", file_path)
56
+ with open(file_path, "r", encoding="utf-8") as file_handler:
57
+ return cls(**json.load(file_handler))
58
+ except (FileNotFoundError, IOError, json.decoder.JSONDecodeError) as err:
59
+ log.debug("Cannot read object from config directory (%s).", err)
60
+
61
+ return None
62
+
63
+ def to_file(self, file_path: str):
64
+ """Save the object to file."""
65
+ try:
66
+ log.debug("Writing JSON file %s", file_path)
67
+ with open(file_path, "w", encoding="utf-8") as file_handler:
68
+ json.dump(self.to_dict(), file_handler)
69
+ except IOError as io_err:
70
+ log.warning("Unable to save file %s (%s)", file_path, io_err)
71
+
72
+ @classmethod
73
+ def delete_from_config_dir(cls):
74
+ """Delete the config file, if there."""
75
+ cfg_file = cls.get_cfg_file_name()
76
+ if cfg_file:
77
+ os.remove(cfg_file)
78
+
79
+
80
+ class JWT(Serializable):
81
+ """JWT model."""
82
+
83
+ access_token: str
84
+ expires_in: int
85
+ refresh_token: str
86
+ refresh_expires_in: int
87
+ token_type: str
88
+
89
+
90
+ class DeviceGrantResponse(BaseModel): # pylint: disable = R0903
91
+ """Device grant login response model."""
92
+
93
+ verification_uri_complete: str
94
+ device_code: str
95
+ expires_in: int
96
+ interval: int
97
+
98
+
99
+ class ApiKey(Serializable):
100
+ """API key class."""
101
+
102
+ access_key: str = Field(alias="access-key")
103
+ secret_key: str = Field(alias="secret-key")
104
+
105
+ @classmethod
106
+ def from_env(cls):
107
+ """Try to load from env."""
108
+ env = Settings()
109
+ if env.tld_access_key and env.tld_secret_key:
110
+ return cls(
111
+ access_key=env.tld_access_key,
112
+ secret_key=env.tld_secret_key,
113
+ )
114
+ return None
115
+
116
+ @classmethod
117
+ def grab(cls):
118
+ """Try to load an API key from env. or file."""
119
+ return cls.from_env() or cls.from_config_dir()