cwms-cli 0.3.3__tar.gz → 0.3.4__tar.gz

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.
Files changed (62) hide show
  1. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/PKG-INFO +1 -1
  2. cwms_cli-0.3.4/cwmscli/commands/blob.py +700 -0
  3. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/commands_cwms.py +49 -5
  4. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/__init__.py +25 -0
  5. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/pyproject.toml +1 -1
  6. cwms_cli-0.3.3/cwmscli/commands/blob.py +0 -437
  7. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/LICENSE +0 -0
  8. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/README.md +0 -0
  9. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/__init__.py +0 -0
  10. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/__main__.py +0 -0
  11. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/callbacks/__init__.py +0 -0
  12. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/.gitignore +0 -0
  13. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/README.md +0 -0
  14. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/__init__.py +0 -0
  15. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/__main__.py +0 -0
  16. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/config.py +0 -0
  17. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/doclinks.py +0 -0
  18. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/examples/complete_config.json +0 -0
  19. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/parser.py +0 -0
  20. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
  21. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/data/.gitignore +0 -0
  22. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +0 -0
  23. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +0 -0
  24. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/data/sample_config.json +0 -0
  25. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +0 -0
  26. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/test_dateutils.py +0 -0
  27. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/test_expressions.py +0 -0
  28. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/test_fileio.py +0 -0
  29. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/test_main.py +0 -0
  30. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/transform.py +0 -0
  31. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/utils/__init__.py +0 -0
  32. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/utils/dateutils.py +0 -0
  33. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/utils/expression.py +0 -0
  34. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/utils/fileio.py +0 -0
  35. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/utils/logging.py +0 -0
  36. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/writer.py +0 -0
  37. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/shef_critfile_import.py +0 -0
  38. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/README.md +0 -0
  39. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/__init__.py +0 -0
  40. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/__main__.py +0 -0
  41. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/location/location.py +0 -0
  42. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/location/location_ids.py +0 -0
  43. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/location/location_ids_bygroup.py +0 -0
  44. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/root.py +0 -0
  45. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/timeseries/timeseries.py +0 -0
  46. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/timeseries/timeseries_ids.py +0 -0
  47. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/requirements.py +0 -0
  48. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/usgs/__init__.py +0 -0
  49. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/usgs/__main__.py +0 -0
  50. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/usgs/getUSGS_ratings_cda.py +0 -0
  51. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/usgs/getusgs_cda.py +0 -0
  52. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/usgs/getusgs_measurements_cda.py +0 -0
  53. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/usgs/rating_ini_file_import.py +0 -0
  54. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/click_help.py +0 -0
  55. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/colors.py +0 -0
  56. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/deps.py +0 -0
  57. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/intervals.py +0 -0
  58. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/io.py +0 -0
  59. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/logging/__init__.py +0 -0
  60. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/logging/formatters.py +0 -0
  61. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/ssl_errors.py +0 -0
  62. {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cwms-cli
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Command line utilities for Corps Water Management Systems (CWMS) python scripts. This is a collection of shared scripts across the enterprise Water Management Enterprise System (WMES) teams.
5
5
  License: LICENSE
6
6
  License-File: LICENSE
@@ -0,0 +1,700 @@
1
+ import base64
2
+ import binascii
3
+ import json
4
+ import logging
5
+ import mimetypes
6
+ import os
7
+ import re
8
+ import sys
9
+ from collections import defaultdict
10
+ from typing import Optional, Sequence, Tuple
11
+
12
+ from cwmscli.utils import colors, get_api_key, log_scoped_read_hint
13
+ from cwmscli.utils.click_help import DOCS_BASE_URL
14
+ from cwmscli.utils.deps import requires
15
+
16
+ # used to rebuild data URL for images
17
+ DATA_URL_RE = re.compile(r"^data:(?P<mime>[^;]+);base64,(?P<data>.+)$", re.I | re.S)
18
+ BASE64_TEXT_RE = re.compile(r"^[A-Za-z0-9+/]+={0,2}$")
19
+ BLOB_DOCS_URL = f"{DOCS_BASE_URL}/cli/blob.html"
20
+
21
+
22
+ @requires(
23
+ {
24
+ "module": "imghdr",
25
+ "package": "standard-imghdr",
26
+ "version": "3.0.0",
27
+ "desc": "Package to help detect image types",
28
+ "link": "https://docs.python.org/3/library/imghdr.html",
29
+ }
30
+ )
31
+ def _determine_ext(data: bytes) -> str:
32
+ """
33
+ Attempt to determine the file extension from the data itself.
34
+ Requires the imghdr module (lazy import) to inspect the bytes for image types.
35
+ If not an image, defaults to .bin
36
+
37
+ Args:
38
+ data: The binary data to inspect.
39
+
40
+ Returns:
41
+ The determined file extension, including the leading dot (e.g., '.png', '.jpg').
42
+ """
43
+ import imghdr
44
+
45
+ kind: Optional[str] = imghdr.what(None, data)
46
+ if kind == "jpeg":
47
+ kind = "jpg"
48
+ return f".{kind}" if kind else ".bin"
49
+
50
+
51
+ def _decode_base64_data(raw: str) -> bytes:
52
+ compact = re.sub(r"\s+", "", raw)
53
+ try:
54
+ return base64.b64decode(compact, validate=True)
55
+ except binascii.Error:
56
+ return base64.b64decode(compact + "=" * (-len(compact) % 4))
57
+
58
+
59
+ def _looks_like_base64(raw: str) -> bool:
60
+ compact = re.sub(r"\s+", "", raw)
61
+ if len(compact) < 16 or len(compact) % 4 != 0:
62
+ return False
63
+ return bool(BASE64_TEXT_RE.fullmatch(compact))
64
+
65
+
66
+ def _save_blob_content(
67
+ content: bytes | str,
68
+ dest: str,
69
+ media_type_hint: Optional[str] = None,
70
+ ) -> str:
71
+ media_type = media_type_hint
72
+ data: bytes | str = content
73
+
74
+ if isinstance(content, str):
75
+ m = DATA_URL_RE.match(content.strip())
76
+ if m:
77
+ media_type = m.group("mime")
78
+ data = _decode_base64_data(m.group("data"))
79
+ elif (
80
+ media_type
81
+ and media_type.lower().startswith("image/")
82
+ and _looks_like_base64(content)
83
+ ):
84
+ data = _decode_base64_data(content)
85
+
86
+ base, ext = os.path.splitext(dest)
87
+
88
+ write_type = "wb" if isinstance(data, bytes) else "w"
89
+ if not ext:
90
+ if media_type:
91
+ ext = mimetypes.guess_extension(media_type.split(";")[0].lower()) or ""
92
+ if ext == ".jpe":
93
+ ext = ".jpg"
94
+ if not ext and isinstance(data, bytes):
95
+ ext = _determine_ext(data)
96
+ dest = base + ext
97
+
98
+ os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
99
+ encoding = None if write_type == "wb" else "utf-8"
100
+ newline = None if write_type == "wb" else ""
101
+ with open(dest, write_type, encoding=encoding, newline=newline) as f:
102
+ f.write(data)
103
+ return dest
104
+
105
+
106
+ def _blob_media_type(cwms_module, office: str, blob_id: str) -> Optional[str]:
107
+ try:
108
+ result = cwms_module.get_blobs(office_id=office, blob_id_like=blob_id)
109
+ except Exception:
110
+ return None
111
+
112
+ df = getattr(result, "df", result)
113
+ if df is None or getattr(df, "empty", True):
114
+ return None
115
+ if "id" not in df.columns or "media-type-id" not in df.columns:
116
+ return None
117
+
118
+ matches = df[df["id"].astype(str).str.upper() == blob_id.upper()]
119
+ if matches.empty:
120
+ return None
121
+
122
+ media_type = matches.iloc[0].get("media-type-id")
123
+ return str(media_type) if media_type else None
124
+
125
+
126
+ def _join_api_url(api_root: str, path: str) -> str:
127
+ return f"{api_root.rstrip('/')}/{path.lstrip('/')}"
128
+
129
+
130
+ def _resolve_optional_api_key(api_key: Optional[str], anonymous: bool) -> Optional[str]:
131
+ if anonymous or not api_key:
132
+ return None
133
+ return get_api_key(api_key, None)
134
+
135
+
136
+ def store_blob(**kwargs):
137
+ import cwms
138
+ import requests
139
+
140
+ file_data = kwargs.get("file_data")
141
+ blob_id = kwargs.get("blob_id", "").upper()
142
+ # Attempt to determine what media type should be used for the mime-type if one is not presented based on the file extension
143
+ media = kwargs.get("media_type") or get_media_type(kwargs.get("input_file"))
144
+
145
+ logging.debug(
146
+ f"Office: {kwargs.get('office')} Output ID: {blob_id} Media: {media}"
147
+ )
148
+
149
+ blob = {
150
+ "office-id": kwargs.get("office"),
151
+ "id": blob_id,
152
+ "description": json.dumps(kwargs.get("description")),
153
+ "media-type-id": media,
154
+ "value": base64.b64encode(file_data).decode("utf-8"),
155
+ }
156
+
157
+ params = {"fail-if-exists": not kwargs.get("overwrite")}
158
+ view_url = _join_api_url(
159
+ kwargs.get("api_root"), f"blobs/{blob_id}?office={kwargs.get('office')}"
160
+ )
161
+
162
+ if kwargs.get("dry_run"):
163
+ logging.info(
164
+ f"--dry-run enabled. Would POST to {kwargs.get('api_root')}/blobs with params={params}"
165
+ )
166
+ logging.info(
167
+ f"Blob payload summary: office-id={kwargs.get('office')}, id={blob_id}, media={media}",
168
+ )
169
+ logging.info(
170
+ json.dumps(
171
+ {
172
+ "url": _join_api_url(kwargs.get("api_root"), "blobs"),
173
+ "params": params,
174
+ "blob": {**blob, "value": f"<base64:{len(blob['value'])} chars>"},
175
+ },
176
+ indent=2,
177
+ )
178
+ )
179
+ sys.exit(0)
180
+
181
+ try:
182
+ cwms.store_blobs(blob, fail_if_exists=kwargs.get("overwrite"))
183
+ logging.info(f"Successfully stored blob with ID: {blob_id}")
184
+ logging.info(f"View: {view_url}")
185
+ except requests.HTTPError as e:
186
+ # Include response text when available
187
+ detail = getattr(e.response, "text", "") or str(e)
188
+ logging.error(f"Failed to store blob (HTTP): {detail}")
189
+ sys.exit(1)
190
+ except Exception as e:
191
+ logging.error(f"Failed to store blob: {e}")
192
+ sys.exit(1)
193
+
194
+
195
+ def retrieve_blob(**kwargs):
196
+ import cwms
197
+ import requests
198
+
199
+ blob_id = kwargs.get("blob_id", "").upper()
200
+ if not blob_id:
201
+ logging.warning(
202
+ "Valid blob_id required to download a blob. cwms-cli blob download --blob-id=myid. Run the list directive to see options for your office."
203
+ )
204
+ sys.exit(0)
205
+ logging.debug(f"Office: {kwargs.get('office')} Blob ID: {blob_id}")
206
+ try:
207
+ blob = cwms.get_blob(
208
+ office_id=kwargs.get("office"),
209
+ blob_id=blob_id,
210
+ )
211
+ logging.info(
212
+ f"Successfully retrieved blob with ID: {blob_id}",
213
+ )
214
+ _save_blob_content(
215
+ blob,
216
+ dest=blob_id,
217
+ media_type_hint=_blob_media_type(cwms, kwargs.get("office"), blob_id),
218
+ )
219
+ logging.info(f"Downloaded blob to: {blob_id}")
220
+ except requests.HTTPError as e:
221
+ detail = getattr(e.response, "text", "") or str(e)
222
+ logging.error(f"Failed to retrieve blob (HTTP): {detail}")
223
+ sys.exit(1)
224
+ except Exception as e:
225
+ logging.error(f"Failed to retrieve blob: {e}")
226
+ sys.exit(1)
227
+
228
+
229
+ def delete_blob(**kwargs):
230
+ import cwms
231
+ import requests
232
+
233
+ blob_id = kwargs.get("blob_id").upper()
234
+ logging.debug(f"Office: {kwargs.get('office')} Blob ID: {blob_id}")
235
+
236
+ try:
237
+ cwms.delete_blob(
238
+ office_id=kwargs.get("office"),
239
+ blob_id=kwargs.get("blob_id").upper(),
240
+ )
241
+ logging.info(f"Successfully deleted blob with ID: {blob_id}")
242
+ except requests.HTTPError as e:
243
+ details = getattr(e.response, "text", "") or str(e)
244
+ logging.error(f"Failed to delete blob (HTTP): {details}")
245
+ sys.exit(1)
246
+ except Exception as e:
247
+ logging.error(f"Failed to delete blob: {e}")
248
+ sys.exit(1)
249
+
250
+
251
+ def list_blobs(
252
+ office: Optional[str] = None,
253
+ blob_id_like: Optional[str] = None,
254
+ columns: Optional[Sequence[str]] = None,
255
+ sort_by: Optional[Sequence[str]] = None,
256
+ ascending: bool = True,
257
+ limit: Optional[int] = None,
258
+ ):
259
+ logging.info(f"Listing blobs for office: {office!r}...")
260
+ import cwms
261
+ import pandas as pd
262
+
263
+ result = cwms.get_blobs(office_id=office, blob_id_like=blob_id_like)
264
+
265
+ # Accept either a DataFrame or a JSON/dict-like response
266
+ if isinstance(result, pd.DataFrame):
267
+ df = result.copy()
268
+ else:
269
+ # Expecting normal blob return structure
270
+ data = getattr(result, "json", None)
271
+ if callable(data):
272
+ data = result.json()
273
+ df = pd.DataFrame((data or {}).get("blobs", []))
274
+
275
+ # Allow column filtering
276
+ if columns:
277
+ keep = [c for c in columns if c in df.columns]
278
+ if keep:
279
+ df = df[keep]
280
+
281
+ # Sort by option
282
+ if sort_by:
283
+ by = [c for c in sort_by if c in df.columns]
284
+ if by:
285
+ df = df.sort_values(by=by, ascending=ascending, kind="stable")
286
+
287
+ # Optional limit
288
+ if limit is not None:
289
+ df = df.head(limit)
290
+
291
+ logging.info(f"Found {len(df):,} blob(s)")
292
+ # List the blobs in the logger
293
+ for _, row in df.iterrows():
294
+ logging.info(f"Blob ID: {row['id']}, Description: {row.get('description')}")
295
+ return df
296
+
297
+
298
+ def get_media_type(file_path: str) -> str:
299
+ mime_type, _ = mimetypes.guess_type(file_path)
300
+ return mime_type or "application/octet-stream"
301
+
302
+
303
+ def _read_file_bytes(input_file: str) -> bytes:
304
+ file_size = os.path.getsize(input_file)
305
+ with open(input_file, "rb") as f:
306
+ file_data = f.read()
307
+ logging.info(f"Read file: {input_file} ({file_size} bytes)")
308
+ return file_data
309
+
310
+
311
+ def _store_blob_payload(
312
+ *,
313
+ file_data: bytes,
314
+ input_file: str,
315
+ blob_id: str,
316
+ description: str,
317
+ media_type: str,
318
+ overwrite: bool,
319
+ office: str,
320
+ ):
321
+ media = media_type or get_media_type(input_file)
322
+ blob_id_up = blob_id.upper()
323
+ logging.debug(f"Office={office} BlobID={blob_id_up} Media={media}")
324
+
325
+ blob = {
326
+ "office-id": office,
327
+ "id": blob_id_up,
328
+ "description": (
329
+ json.dumps(description)
330
+ if isinstance(description, (dict, list))
331
+ else description
332
+ ),
333
+ "media-type-id": media,
334
+ "value": base64.b64encode(file_data).decode("utf-8"),
335
+ }
336
+ params = {"fail-if-exists": not overwrite}
337
+ return blob, params, blob_id_up
338
+
339
+
340
+ def _list_matching_files(
341
+ input_dir: str, file_regex: str, recursive: bool
342
+ ) -> list[Tuple[str, str]]:
343
+ try:
344
+ pattern = re.compile(file_regex)
345
+ except re.error as e:
346
+ raise ValueError(f"Invalid --file-regex: {e}") from e
347
+
348
+ matches: list[Tuple[str, str]] = []
349
+ for root, _, files in os.walk(input_dir):
350
+ for name in files:
351
+ full_path = os.path.join(root, name)
352
+ rel_path = os.path.relpath(full_path, input_dir).replace(os.sep, "/")
353
+ if pattern.search(rel_path):
354
+ matches.append((full_path, rel_path))
355
+ if not recursive:
356
+ break
357
+ matches.sort(key=lambda x: x[1].lower())
358
+ return matches
359
+
360
+
361
+ def _blob_id_for_path(input_dir: str, rel_path: str, blob_id_prefix: str) -> str:
362
+ rel_no_ext = os.path.splitext(rel_path)[0].replace("/", "_")
363
+ return f"{blob_id_prefix}{rel_no_ext}".upper()
364
+
365
+
366
+ def _find_blob_id_collisions(
367
+ matches: list[Tuple[str, str]], input_dir: str, blob_id_prefix: str
368
+ ) -> dict[str, list[str]]:
369
+ collisions: dict[str, list[str]] = defaultdict(list)
370
+ for _, rel_path in matches:
371
+ blob_id = _blob_id_for_path(
372
+ input_dir=input_dir,
373
+ rel_path=rel_path,
374
+ blob_id_prefix=blob_id_prefix,
375
+ )
376
+ collisions[blob_id].append(rel_path)
377
+ return {
378
+ blob_id: rel_paths
379
+ for blob_id, rel_paths in collisions.items()
380
+ if len(rel_paths) > 1
381
+ }
382
+
383
+
384
+ def upload_cmd(
385
+ input_file: Optional[str],
386
+ input_dir: Optional[str],
387
+ file_regex: str,
388
+ recursive: bool,
389
+ blob_id: Optional[str],
390
+ blob_id_prefix: str,
391
+ description: str,
392
+ media_type: str,
393
+ overwrite: bool,
394
+ dry_run: bool,
395
+ office: str,
396
+ api_root: str,
397
+ api_key: str,
398
+ ):
399
+ import cwms
400
+ import requests
401
+
402
+ cwms.init_session(api_root=api_root, api_key=get_api_key(api_key, None))
403
+
404
+ using_single = bool(input_file)
405
+ using_multi = bool(input_dir)
406
+ if using_single == using_multi:
407
+ logging.error("Choose exactly one input source: --input-file or --input-dir.")
408
+ sys.exit(2)
409
+
410
+ uploads: list[Tuple[str, str]] = []
411
+ if using_single:
412
+ if not blob_id:
413
+ logging.error("--blob-id is required when using --input-file.")
414
+ sys.exit(2)
415
+ uploads = [(input_file, blob_id)]
416
+ else:
417
+ try:
418
+ matches = _list_matching_files(input_dir, file_regex, recursive)
419
+ except ValueError as e:
420
+ logging.error(str(e))
421
+ sys.exit(2)
422
+ if not matches:
423
+ logging.error(
424
+ f"No files in {input_dir!r} matched --file-regex {file_regex!r}."
425
+ )
426
+ sys.exit(1)
427
+ collisions = _find_blob_id_collisions(matches, input_dir, blob_id_prefix)
428
+ if collisions:
429
+ for blob_id, rel_paths in collisions.items():
430
+ logging.error(
431
+ "Generated blob ID collision for %s from files: %s",
432
+ blob_id,
433
+ ", ".join(rel_paths),
434
+ )
435
+ logging.error(
436
+ "Bulk upload aborted. Adjust file names or use --blob-id-prefix to avoid duplicate generated blob IDs. Docs: %s#blob-bulk-collisions",
437
+ BLOB_DOCS_URL,
438
+ )
439
+ sys.exit(2)
440
+ uploads = [
441
+ (
442
+ full_path,
443
+ _blob_id_for_path(
444
+ input_dir=input_dir,
445
+ rel_path=rel_path,
446
+ blob_id_prefix=blob_id_prefix,
447
+ ),
448
+ )
449
+ for full_path, rel_path in matches
450
+ ]
451
+ logging.info(
452
+ colors.c(
453
+ f"Matched {len(uploads)} file(s) in {input_dir} with regex: {file_regex}",
454
+ "cyan",
455
+ bright=True,
456
+ )
457
+ )
458
+
459
+ failures = 0
460
+ for file_path, next_blob_id in uploads:
461
+ try:
462
+ file_data = _read_file_bytes(file_path)
463
+ blob, params, blob_id_up = _store_blob_payload(
464
+ file_data=file_data,
465
+ input_file=file_path,
466
+ blob_id=next_blob_id,
467
+ description=description,
468
+ media_type=media_type,
469
+ overwrite=overwrite,
470
+ office=office,
471
+ )
472
+ if dry_run:
473
+ logging.info(
474
+ colors.c(
475
+ f"[DRY RUN] {file_path} -> {blob_id_up}",
476
+ "yellow",
477
+ bright=True,
478
+ )
479
+ )
480
+ logging.info(
481
+ json.dumps(
482
+ {
483
+ "url": _join_api_url(api_root, "blobs"),
484
+ "params": params,
485
+ "blob": {
486
+ **blob,
487
+ "value": f'<base64:{len(blob["value"])} chars>',
488
+ },
489
+ },
490
+ indent=2,
491
+ )
492
+ )
493
+ continue
494
+
495
+ cwms.store_blobs(blob, fail_if_exists=not overwrite)
496
+ view_url = _join_api_url(api_root, f"blobs/{blob_id_up}?office={office}")
497
+ logging.info(
498
+ colors.c(
499
+ f"[OK] Uploaded {file_path} as {blob_id_up}",
500
+ "green",
501
+ bright=True,
502
+ )
503
+ )
504
+ logging.info(f"View: {view_url}")
505
+ except requests.HTTPError as e:
506
+ failures += 1
507
+ detail = getattr(e.response, "text", "") or str(e)
508
+ logging.error(
509
+ colors.c(
510
+ f"[FAIL] {file_path} -> {next_blob_id.upper()} (HTTP): {detail}",
511
+ "red",
512
+ bright=True,
513
+ )
514
+ )
515
+ except Exception as e:
516
+ failures += 1
517
+ logging.error(
518
+ colors.c(
519
+ f"[FAIL] {file_path} -> {next_blob_id.upper()}: {e}",
520
+ "red",
521
+ bright=True,
522
+ )
523
+ )
524
+
525
+ success_count = len(uploads) - failures
526
+ if failures:
527
+ logging.warning(
528
+ colors.c(
529
+ f"Upload completed with failures: {success_count}/{len(uploads)} succeeded, {failures} failed.",
530
+ "yellow",
531
+ bright=True,
532
+ )
533
+ )
534
+ sys.exit(1)
535
+ logging.info(
536
+ colors.c(
537
+ f"Upload completed successfully: {success_count}/{len(uploads)} file(s).",
538
+ "green",
539
+ bright=True,
540
+ )
541
+ )
542
+
543
+
544
+ def download_cmd(
545
+ blob_id: str,
546
+ dest: str,
547
+ office: str,
548
+ api_root: str,
549
+ api_key: str,
550
+ dry_run: bool,
551
+ anonymous: bool = False,
552
+ ):
553
+ import cwms
554
+ import requests
555
+
556
+ if dry_run:
557
+ logging.info(
558
+ f"DRY RUN: would GET {api_root} blob with blob-id={blob_id} office={office}."
559
+ )
560
+ return
561
+ resolved_api_key = _resolve_optional_api_key(api_key, anonymous)
562
+ cwms.init_session(api_root=api_root, api_key=resolved_api_key)
563
+ bid = blob_id.upper()
564
+ logging.debug(f"Office={office} BlobID={bid}")
565
+
566
+ try:
567
+ blob_content = cwms.get_blob(office_id=office, blob_id=bid)
568
+ target = dest or bid
569
+ _save_blob_content(
570
+ blob_content,
571
+ dest=target,
572
+ media_type_hint=_blob_media_type(cwms, office, bid),
573
+ )
574
+ logging.info(f"Downloaded blob to: {target}")
575
+ except requests.HTTPError as e:
576
+ detail = getattr(e.response, "text", "") or str(e)
577
+ logging.error(f"Failed to download (HTTP): {detail}")
578
+ log_scoped_read_hint(
579
+ api_key=resolved_api_key,
580
+ anonymous=anonymous,
581
+ office=office,
582
+ action="download",
583
+ resource="blob content",
584
+ )
585
+ sys.exit(1)
586
+ except Exception as e:
587
+ logging.error(f"Failed to download: {e}")
588
+ log_scoped_read_hint(
589
+ api_key=resolved_api_key,
590
+ anonymous=anonymous,
591
+ office=office,
592
+ action="download",
593
+ resource="blob content",
594
+ )
595
+ sys.exit(1)
596
+
597
+
598
+ def delete_cmd(blob_id: str, office: str, api_root: str, api_key: str, dry_run: bool):
599
+ import cwms
600
+
601
+ if dry_run:
602
+ logging.info(
603
+ f"DRY RUN: would DELETE {api_root} blob with blob-id={blob_id} office={office}"
604
+ )
605
+ return
606
+ cwms.init_session(api_root=api_root, api_key=api_key)
607
+ cwms.delete_blob(office_id=office, blob_id=blob_id)
608
+ logging.info(f"Deleted blob: {blob_id} for office: {office}")
609
+
610
+
611
+ def update_cmd(
612
+ input_file: str,
613
+ blob_id: str,
614
+ description: str,
615
+ media_type: str,
616
+ overwrite: bool,
617
+ dry_run: bool,
618
+ office: str,
619
+ api_root: str,
620
+ api_key: str,
621
+ ):
622
+ import cwms
623
+
624
+ if dry_run:
625
+ logging.info(
626
+ f"DRY RUN: would PATCH {api_root} blob with blob-id={blob_id} office={office}"
627
+ )
628
+ return
629
+ cwms.init_session(api_root=api_root, api_key=api_key)
630
+ file_data = None
631
+ if input_file:
632
+ try:
633
+ file_size = os.path.getsize(input_file)
634
+ with open(input_file, "rb") as f:
635
+ file_data = f.read()
636
+ logging.info(f"Read file: {input_file} ({file_size} bytes)")
637
+ except Exception as e:
638
+ logging.error(f"Failed to read file: {e}")
639
+ sys.exit(1)
640
+ # Setup minimum required payload
641
+ blob = {"office-id": office, "id": blob_id.upper()}
642
+ if description:
643
+ blob["description"] = description
644
+ if media_type:
645
+ blob["media-type-id"] = media_type
646
+ else:
647
+ logging.info("Media type not specified; Retrieving existing media type...")
648
+ blob_metadata = cwms.get_blobs(office_id=office, blob_id_like=blob_id)
649
+ blob["media-type-id"] = blob_metadata.df.get(
650
+ "media-type-id", "application/octet-stream"
651
+ )[0]
652
+ logging.info(f"Using existing media type: {blob['media-type-id']}")
653
+
654
+ if file_data:
655
+ blob["value"] = base64.b64encode(file_data).decode("utf-8")
656
+ cwms.update_blob(blob, fail_if_not_exists=not overwrite)
657
+
658
+
659
+ def list_cmd(
660
+ blob_id_like: str,
661
+ columns: list[str],
662
+ sort_by: list[str],
663
+ desc: bool,
664
+ limit: int,
665
+ to_csv: str,
666
+ office: str,
667
+ api_root: str,
668
+ api_key: str,
669
+ anonymous: bool = False,
670
+ ):
671
+ import cwms
672
+ import pandas as pd
673
+
674
+ resolved_api_key = _resolve_optional_api_key(api_key, anonymous)
675
+ cwms.init_session(api_root=api_root, api_key=resolved_api_key)
676
+ try:
677
+ df = list_blobs(
678
+ office=office,
679
+ blob_id_like=blob_id_like,
680
+ columns=columns,
681
+ sort_by=sort_by,
682
+ ascending=not desc,
683
+ limit=limit,
684
+ )
685
+ except Exception:
686
+ log_scoped_read_hint(
687
+ api_key=resolved_api_key,
688
+ anonymous=anonymous,
689
+ office=office,
690
+ action="list",
691
+ resource="blob content",
692
+ )
693
+ raise
694
+ if to_csv:
695
+ df.to_csv(to_csv, index=False)
696
+ logging.info(f"Wrote {len(df)} rows to {to_csv}")
697
+ else:
698
+ # Friendly console preview
699
+ with pd.option_context("display.max_rows", 500, "display.max_columns", None):
700
+ logging.info(df.to_string(index=False))