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.
- teledetection/__init__.py +21 -0
- teledetection/cli.py +364 -0
- teledetection/sdk/__init__.py +1 -0
- teledetection/sdk/http.py +124 -0
- teledetection/sdk/logger.py +15 -0
- teledetection/sdk/model.py +119 -0
- teledetection/sdk/oauth2.py +214 -0
- teledetection/sdk/py.typed +0 -0
- teledetection/sdk/settings.py +59 -0
- teledetection/sdk/signing.py +507 -0
- teledetection/sdk/utils.py +21 -0
- teledetection/upload/__init__.py +1 -0
- teledetection/upload/diff.py +102 -0
- teledetection/upload/py.typed +0 -0
- teledetection/upload/raster.py +351 -0
- teledetection/upload/stac.py +420 -0
- teledetection/upload/transfer.py +20 -0
- teledetection-0.1.0.dist-info/METADATA +34 -0
- teledetection-0.1.0.dist-info/RECORD +23 -0
- teledetection-0.1.0.dist-info/WHEEL +5 -0
- teledetection-0.1.0.dist-info/entry_points.txt +2 -0
- teledetection-0.1.0.dist-info/licenses/LICENSE +201 -0
- teledetection-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|