cwms-cli 0.1.1__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 (40) hide show
  1. cwms_cli-0.1.1/LICENSE +21 -0
  2. cwms_cli-0.1.1/PKG-INFO +40 -0
  3. cwms_cli-0.1.1/README.md +17 -0
  4. cwms_cli-0.1.1/cwmscli/__init__.py +12 -0
  5. cwms_cli-0.1.1/cwmscli/__main__.py +15 -0
  6. cwms_cli-0.1.1/cwmscli/callbacks/__init__.py +18 -0
  7. cwms_cli-0.1.1/cwmscli/commands/blob.py +439 -0
  8. cwms_cli-0.1.1/cwmscli/commands/commands_cwms.py +227 -0
  9. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/.gitignore +3 -0
  10. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/README.md +51 -0
  11. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/__init__.py +5 -0
  12. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/__main__.py +265 -0
  13. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/examples/complete_config.json +19 -0
  14. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/examples/hourly.json +243 -0
  15. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/examples/minutes.json +315 -0
  16. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
  17. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/data/.gitignore +1 -0
  18. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +278 -0
  19. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +9 -0
  20. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/data/sample_config.json +45 -0
  21. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +35 -0
  22. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/test_dateutils.py +68 -0
  23. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/test_expressions.py +49 -0
  24. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/test_fileio.py +43 -0
  25. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/utils/__init__.py +5 -0
  26. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/utils/dateutils.py +105 -0
  27. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/utils/expression.py +39 -0
  28. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/utils/fileio.py +26 -0
  29. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/utils/logging.py +80 -0
  30. cwms_cli-0.1.1/cwmscli/commands/csv2cwms/utils/terminal.py +45 -0
  31. cwms_cli-0.1.1/cwmscli/commands/shef_critfile_import.py +146 -0
  32. cwms_cli-0.1.1/cwmscli/requirements.py +25 -0
  33. cwms_cli-0.1.1/cwmscli/usgs/__init__.py +161 -0
  34. cwms_cli-0.1.1/cwmscli/usgs/getUSGS_ratings_cda.py +346 -0
  35. cwms_cli-0.1.1/cwmscli/usgs/getusgs_cda.py +345 -0
  36. cwms_cli-0.1.1/cwmscli/usgs/getusgs_measurements_cda.py +961 -0
  37. cwms_cli-0.1.1/cwmscli/usgs/rating_ini_file_import.py +130 -0
  38. cwms_cli-0.1.1/cwmscli/utils/__init__.py +68 -0
  39. cwms_cli-0.1.1/cwmscli/utils/deps.py +102 -0
  40. cwms_cli-0.1.1/pyproject.toml +47 -0
cwms_cli-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Hydrologic Engineering Center
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.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: cwms-cli
3
+ Version: 0.1.1
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
+ License: LICENSE
6
+ License-File: LICENSE
7
+ Keywords: USACE,CWMS,CLI,Hydrologic Engineering Center,HEC,Hydrology,Hydraulics,Water Resources,DSS
8
+ Author: Eric Novotny
9
+ Author-email: eric.v.novotny@usace.army.mil
10
+ Requires-Python: >=3.9,<4.0
11
+ Classifier: License :: Other/Proprietary License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Requires-Dist: click (>=8.1.8,<9.0.0)
20
+ Project-URL: Repository, https://github.com/HydrologicEngineeringCenter/cwms-cli
21
+ Description-Content-Type: text/markdown
22
+
23
+ # cwms-cli
24
+
25
+ command line utilities used for Corps Water Management Systems (CWMS) processes
26
+
27
+ [![Docs](https://readthedocs.org/projects/cwms-cli/badge/?version=latest)](https://cwms-cli.readthedocs.io/en/latest/)
28
+
29
+ ## Install
30
+
31
+ ```sh
32
+ pip install git+https://github.com/HydrologicEngineeringCenter/cwms-cli.git@main
33
+ ```
34
+
35
+ ## Command line implementation
36
+
37
+ ```sh
38
+ cwms-cli --help
39
+ ```
40
+
@@ -0,0 +1,17 @@
1
+ # cwms-cli
2
+
3
+ command line utilities used for Corps Water Management Systems (CWMS) processes
4
+
5
+ [![Docs](https://readthedocs.org/projects/cwms-cli/badge/?version=latest)](https://cwms-cli.readthedocs.io/en/latest/)
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pip install git+https://github.com/HydrologicEngineeringCenter/cwms-cli.git@main
11
+ ```
12
+
13
+ ## Command line implementation
14
+
15
+ ```sh
16
+ cwms-cli --help
17
+ ```
@@ -0,0 +1,12 @@
1
+ import logging as lg
2
+
3
+ # create logging for logging
4
+ logging = lg.getLogger()
5
+ if logging.hasHandlers():
6
+ logging.handlers.clear()
7
+ handler = lg.StreamHandler()
8
+ formatter = lg.Formatter("%(asctime)s;%(levelname)s;%(message)s", "%Y-%m-%d %H:%M:%S")
9
+ handler.setFormatter(formatter)
10
+ logging.addHandler(handler)
11
+ logging.setLevel(lg.INFO)
12
+ logging.propagate = False
@@ -0,0 +1,15 @@
1
+ import click
2
+
3
+ from cwmscli.commands import commands_cwms
4
+ from cwmscli.usgs import usgs_group
5
+
6
+
7
+ @click.group()
8
+ def cli():
9
+ pass
10
+
11
+
12
+ cli.add_command(usgs_group, name="usgs")
13
+ cli.add_command(commands_cwms.shefcritimport)
14
+ cli.add_command(commands_cwms.csv2cwms_cmd)
15
+ cli.add_command(commands_cwms.blob_group)
@@ -0,0 +1,18 @@
1
+ # Click callbacks for click
2
+
3
+
4
+ def csv_to_list(ctx, param, value):
5
+ """Accept multiple values either via repeated flags or a single comma-delimited string."""
6
+ if value is None:
7
+ return None
8
+ if isinstance(value, (list, tuple)):
9
+ out = []
10
+ for v in value:
11
+ if isinstance(v, str) and "," in v:
12
+ out.extend([p.strip() for p in v.split(",") if p.strip()])
13
+ else:
14
+ out.append(v)
15
+ return tuple(out)
16
+ if isinstance(value, str):
17
+ return tuple([p.strip() for p in value.split(",") if p.strip()])
18
+ return value
@@ -0,0 +1,439 @@
1
+ import base64
2
+ import json
3
+ import logging
4
+ import mimetypes
5
+ import os
6
+ import re
7
+ import sys
8
+ from typing import Optional, Sequence
9
+
10
+ import cwms
11
+ import pandas as pd
12
+ import requests
13
+
14
+ from cwmscli.utils import get_api_key
15
+ from cwmscli.utils.deps import requires
16
+
17
+ # used to rebuild data URL for images
18
+ DATA_URL_RE = re.compile(r"^data:(?P<mime>[^;]+);base64,(?P<data>.+)$", re.I | re.S)
19
+
20
+
21
+ @requires(
22
+ {
23
+ "module": "imghdr",
24
+ "package": "standard-imghdr",
25
+ "version": "3.0.0",
26
+ "desc": "Package to help detect image types",
27
+ "link": "https://docs.python.org/3/library/imghdr.html",
28
+ }
29
+ )
30
+ def _determine_ext(data: bytes | str, write_type: str) -> str:
31
+ """
32
+ Attempt to determine the file extension from the data itself.
33
+ Requires the imghdr module (lazy import) to inspect the bytes for image types.
34
+ If not an image, defaults to .bin
35
+
36
+ Args:
37
+ data: The binary data or base64 string to inspect.
38
+ write_type: The mode in which the data will be written ('wb' for binary, 'w' for text).
39
+
40
+ Returns:
41
+ The determined file extension, including the leading dot (e.g., '.png', '.jpg').
42
+ """
43
+ import imghdr
44
+
45
+ kind = imghdr.what(None, data)
46
+ if kind == "jpeg":
47
+ kind = "jpg"
48
+ return f".{kind}" if kind else ".bin"
49
+
50
+
51
+ def _save_base64(
52
+ b64_or_dataurl: str,
53
+ dest: str,
54
+ media_type_hint: str | None = None,
55
+ ) -> str:
56
+ m = DATA_URL_RE.match(b64_or_dataurl.strip())
57
+ if m:
58
+ media_type = m.group("mime")
59
+ b64 = m.group("data")
60
+ else:
61
+ media_type = media_type_hint
62
+ b64 = b64_or_dataurl
63
+ data = b64
64
+ compact = re.sub(r"\s+", "", b64)
65
+ base, ext = os.path.splitext(dest)
66
+ # If an image was uploaded, convert it back from base64 encoding
67
+ # TODO: probably should handle this better in cwms-python?
68
+ write_type = "w"
69
+ if ext.lower() in [".png", ".jpg"]:
70
+ write_type = "wb"
71
+ try:
72
+ data = base64.b64decode(compact, validate=True)
73
+ except Exception:
74
+ data = base64.b64decode(compact + "=" * (-len(compact) % 4))
75
+ if not ext:
76
+ # guess extension from mime or bytes
77
+ if media_type:
78
+ ext = mimetypes.guess_extension(media_type.split(";")[0].lower()) or ""
79
+ if ext == ".jpe":
80
+ ext = ".jpg"
81
+ # last resort, try to determine from the data itself
82
+ # requires imghdr to dig into the bytes to determine image type
83
+ if not ext:
84
+ ext = _determine_ext(data, write_type)
85
+ dest = base + ext
86
+
87
+ os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
88
+ with open(dest, write_type) as f:
89
+ f.write(data)
90
+ return dest
91
+
92
+
93
+ def store_blob(**kwargs):
94
+ file_data = kwargs.get("file_data")
95
+ blob_id = kwargs.get("blob_id").upper()
96
+ # Attempt to determine what media type should be used for the mime-type if one is not presented based on the file extension
97
+ media = kwargs.get("media_type") or get_media_type(kwargs.get("input_file"))
98
+
99
+ logging.debug(
100
+ f"Office: {kwargs.get('office')} Output ID: {blob_id} Media: {media}"
101
+ )
102
+
103
+ blob = {
104
+ "office-id": kwargs.get("office"),
105
+ "id": blob_id,
106
+ "description": json.dumps(kwargs.get("description")),
107
+ "media-type-id": media,
108
+ "value": base64.b64encode(file_data).decode("utf-8"),
109
+ }
110
+
111
+ params = {"fail-if-exists": not kwargs.get("overwrite")}
112
+
113
+ if kwargs.get("dry_run"):
114
+ logging.info(
115
+ f"--dry-run enabled. Would POST to {kwargs.get('api_root')}/blobs with params={params}"
116
+ )
117
+ logging.info(
118
+ f"Blob payload summary: office-id={kwargs.get('office')}, id={blob_id}, media={media}",
119
+ )
120
+ logging.info(
121
+ json.dumps(
122
+ {
123
+ "url": f"{kwargs.get('api_root')}blobs",
124
+ "params": params,
125
+ "blob": {**blob, "value": f"<base64:{len(blob['value'])} chars>"},
126
+ },
127
+ indent=2,
128
+ )
129
+ )
130
+ sys.exit(0)
131
+
132
+ try:
133
+ cwms.store_blobs(blob, fail_if_exists=kwargs.get("overwrite"))
134
+ logging.info(f"Successfully stored blob with ID: {blob_id}")
135
+ logging.info(
136
+ f"View: {kwargs.get('api_root')}blobs/{blob_id}?office={kwargs.get('office')}"
137
+ )
138
+ except requests.HTTPError as e:
139
+ # Include response text when available
140
+ detail = getattr(e.response, "text", "") or str(e)
141
+ logging.error(f"Failed to store blob (HTTP): {detail}")
142
+ sys.exit(1)
143
+ except Exception as e:
144
+ logging.error(f"Failed to store blob: {e}")
145
+ sys.exit(1)
146
+
147
+
148
+ def retrieve_blob(**kwargs):
149
+ blob_id = kwargs.get("blob_id", "")
150
+ if not blob_id:
151
+ logging.warning(
152
+ "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."
153
+ )
154
+ sys.exit(0)
155
+ blob_id = blob_id.upper()
156
+ logging.debug(f"Office: {kwargs.get('office')} Blob ID: {blob_id}")
157
+ try:
158
+ blob = cwms.get_blob(
159
+ office_id=kwargs.get("office"),
160
+ blob_id=blob_id,
161
+ )
162
+ logging.info(
163
+ f"Successfully retrieved blob with ID: {blob_id}",
164
+ )
165
+ _save_base64(blob, dest=blob_id)
166
+ logging.info(f"Downloaded blob to: {blob_id}")
167
+ except requests.HTTPError as e:
168
+ detail = getattr(e.response, "text", "") or str(e)
169
+ logging.error(f"Failed to retrieve blob (HTTP): {detail}")
170
+ sys.exit(1)
171
+ except Exception as e:
172
+ logging.error(f"Failed to retrieve blob: {e}")
173
+ sys.exit(1)
174
+
175
+
176
+ def delete_blob(**kwargs):
177
+ blob_id = kwargs.get("blob_id").upper()
178
+ logging.debug(f"Office: {kwargs.get('office')} Blob ID: {blob_id}")
179
+
180
+ try:
181
+ # cwms.delete_blob(
182
+ # office_id=kwargs.get("office"),
183
+ # blob_id=kwargs.get("blob_id").upper(),
184
+ # )
185
+ logging.info(f"Successfully deleted blob with ID: {blob_id}")
186
+ except requests.HTTPError as e:
187
+ details = getattr(e.response, "text", "") or str(e)
188
+ logging.error(f"Failed to delete blob (HTTP): {details}")
189
+ sys.exit(1)
190
+ except Exception as e:
191
+ logging.error(f"Failed to delete blob: {e}")
192
+ sys.exit(1)
193
+
194
+
195
+ def list_blobs(
196
+ office: Optional[str] = None,
197
+ blob_id_like: Optional[str] = None,
198
+ columns: Optional[Sequence[str]] = None,
199
+ sort_by: Optional[Sequence[str]] = None,
200
+ ascending: bool = True,
201
+ limit: Optional[int] = None,
202
+ ) -> pd.DataFrame:
203
+ logging.info(f"Listing blobs for office: {office!r}...")
204
+ result = cwms.get_blobs(office_id=office, blob_id_like=blob_id_like)
205
+
206
+ # Accept either a DataFrame or a JSON/dict-like response
207
+ if isinstance(result, pd.DataFrame):
208
+ df = result.copy()
209
+ else:
210
+ # Expecting normal blob return structure
211
+ data = getattr(result, "json", None)
212
+ if callable(data):
213
+ data = result.json()
214
+ df = pd.DataFrame((data or {}).get("blobs", []))
215
+
216
+ # Allow column filtering
217
+ if columns:
218
+ keep = [c for c in columns if c in df.columns]
219
+ if keep:
220
+ df = df[keep]
221
+
222
+ # Sort by option
223
+ if sort_by:
224
+ by = [c for c in sort_by if c in df.columns]
225
+ if by:
226
+ df = df.sort_values(by=by, ascending=ascending, kind="stable")
227
+
228
+ # Optional limit
229
+ if limit is not None:
230
+ df = df.head(limit)
231
+
232
+ logging.info(f"Found {len(df):,} blobs")
233
+ # List the blobs in the logger
234
+ for _, row in df.iterrows():
235
+ logging.info(f"Blob ID: {row['id']}, Description: {row.get('description')}")
236
+ return df
237
+
238
+
239
+ def get_media_type(file_path: str) -> str:
240
+ mime_type, _ = mimetypes.guess_type(file_path)
241
+ return mime_type or "application/octet-stream"
242
+
243
+
244
+ def main(
245
+ directive: str,
246
+ input_file: str,
247
+ blob_id: str,
248
+ description: Optional[str],
249
+ media_type: Optional[str],
250
+ office: str,
251
+ api_root: str,
252
+ api_key: str,
253
+ overwrite: Optional[bool] = True,
254
+ dry_run: Optional[bool] = False,
255
+ ):
256
+ """
257
+ Upload, Download, Delete, or Update blob data in CWMS.
258
+
259
+ DIRECTIVE is the action to perform (upload, download, delete, update).
260
+ INPUT_FILE is the path to the file on disk.
261
+ BLOB_ID is the blob ID to store under.
262
+ """
263
+
264
+ cwms.api.init_session(api_root=api_root, api_key=api_key)
265
+ file_data = None
266
+ if directive in ["upload", "update"]:
267
+ if not input_file or not os.path.isfile(input_file):
268
+ logging.warning(
269
+ "Valid input_file required for upload/update. Use --input-file to specify."
270
+ )
271
+ sys.exit(0)
272
+ try:
273
+ file_size = os.path.getsize(input_file)
274
+ with open(input_file, "rb") as f:
275
+ file_data = f.read()
276
+ logging.info(f"Read file: {input_file} ({file_size} bytes)")
277
+ except Exception as e:
278
+ logging.error(f"Failed to read file: {e}")
279
+ sys.exit(1)
280
+
281
+ # Determine what should be done based on directive
282
+ if directive == "upload":
283
+ store_blob(
284
+ office=office,
285
+ api_root=api_root,
286
+ input_file=input_file,
287
+ blob_id=blob_id,
288
+ description=description,
289
+ media_type=media_type,
290
+ file_data=file_data,
291
+ overwrite=overwrite,
292
+ dry_run=dry_run,
293
+ )
294
+ elif directive == "list":
295
+ list_blobs(office=office, blob_id_like=blob_id, sort_by="blob_id")
296
+ elif directive == "download":
297
+ retrieve_blob(
298
+ office=office,
299
+ blob_id=blob_id,
300
+ )
301
+ elif directive == "delete":
302
+ # TODO: Delete endpoint does not exist in cwms-python yet
303
+ logging.warning(
304
+ "[NOT IMPLEMENTED] Delete Blob is not supported yet!\n\thttps://github.com/HydrologicEngineeringCenter/cwms-python/issues/192"
305
+ )
306
+ pass
307
+ elif directive == "update":
308
+ # TODO: Patch endpoint does not exist in cwms-python yet
309
+ logging.warning(
310
+ "[NOT IMPLEMENTED] Update Blob is not supported yet! Consider overwriting instead if a rename is not needed.\n\thttps://github.com/HydrologicEngineeringCenter/cwms-python/issues/192"
311
+ )
312
+ pass
313
+
314
+
315
+ def upload_cmd(
316
+ input_file: str,
317
+ blob_id: str,
318
+ description: str,
319
+ media_type: str,
320
+ overwrite: bool,
321
+ dry_run: bool,
322
+ office: str,
323
+ api_root: str,
324
+ api_key: str,
325
+ ):
326
+ cwms.api.init_session(api_root=api_root, api_key=get_api_key(api_key, ""))
327
+ try:
328
+ file_size = os.path.getsize(input_file)
329
+ with open(input_file, "rb") as f:
330
+ file_data = f.read()
331
+ logging.info(f"Read file: {input_file} ({file_size} bytes)")
332
+ except Exception as e:
333
+ logging.error(f"Failed to read file: {e}")
334
+ sys.exit(1)
335
+
336
+ media = media_type or get_media_type(input_file)
337
+ blob_id_up = blob_id.upper()
338
+ logging.debug(f"Office={office} BlobID={blob_id_up} Media={media}")
339
+
340
+ blob = {
341
+ "office-id": office,
342
+ "id": blob_id_up,
343
+ "description": (
344
+ json.dumps(description)
345
+ if isinstance(description, (dict, list))
346
+ else description
347
+ ),
348
+ "media-type-id": media,
349
+ "value": base64.b64encode(file_data).decode("utf-8"),
350
+ }
351
+ params = {"fail-if-exists": not overwrite}
352
+
353
+ if dry_run:
354
+ logging.info(f"--dry-run: would POST {api_root}blobs with params={params}")
355
+ logging.info(
356
+ json.dumps(
357
+ {
358
+ "url": f"{api_root}blobs",
359
+ "params": params,
360
+ "blob": {**blob, "value": f'<base64:{len(blob["value"])} chars>'},
361
+ },
362
+ indent=2,
363
+ )
364
+ )
365
+ return
366
+
367
+ try:
368
+ cwms.store_blobs(blob, fail_if_exists=overwrite)
369
+ logging.info(f"Uploaded blob: {blob_id_up}")
370
+ logging.info(f"View: {api_root}blobs/{blob_id_up}?office={office}")
371
+ except requests.HTTPError as e:
372
+ detail = getattr(e.response, "text", "") or str(e)
373
+ logging.error(f"Failed to upload (HTTP): {detail}")
374
+ sys.exit(1)
375
+ except Exception as e:
376
+ logging.error(f"Failed to upload: {e}")
377
+ sys.exit(1)
378
+
379
+
380
+ def download_cmd(blob_id: str, dest: str, office: str, api_root: str, api_key: str):
381
+ cwms.api.init_session(api_root=api_root, api_key=get_api_key(api_key, ""))
382
+ bid = blob_id.upper()
383
+ logging.debug(f"Office={office} BlobID={bid}")
384
+
385
+ try:
386
+ blob_b64 = cwms.get_blob(office_id=office, blob_id=bid)
387
+ target = dest or bid
388
+ _save_base64(blob_b64, dest=target)
389
+ logging.info(f"Downloaded blob to: {target}")
390
+ except requests.HTTPError as e:
391
+ detail = getattr(e.response, "text", "") or str(e)
392
+ logging.error(f"Failed to download (HTTP): {detail}")
393
+ sys.exit(1)
394
+ except Exception as e:
395
+ logging.error(f"Failed to download: {e}")
396
+ sys.exit(1)
397
+
398
+
399
+ def delete_cmd(blob_id: str, office: str, api_root: str, api_key: str):
400
+ logging.warning(
401
+ "[NOT IMPLEMENTED] Delete Blob is not supported yet.\n"
402
+ "See: https://github.com/HydrologicEngineeringCenter/cwms-python/issues/192"
403
+ )
404
+
405
+
406
+ def update_cmd(blob_id: str, input_file: str, office: str, api_root: str, api_key: str):
407
+ logging.warning(
408
+ "[NOT IMPLEMENTED] Update Blob is not supported yet. Consider --overwrite with upload.\n"
409
+ "See: https://github.com/HydrologicEngineeringCenter/cwms-python/issues/192"
410
+ )
411
+
412
+
413
+ def list_cmd(
414
+ blob_id_like: str,
415
+ columns: list[str],
416
+ sort_by: list[str],
417
+ desc: bool,
418
+ limit: int,
419
+ to_csv: str,
420
+ office: str,
421
+ api_root: str,
422
+ api_key: str,
423
+ ):
424
+ cwms.api.init_session(api_root=api_root, api_key=get_api_key(api_key, None))
425
+ df = list_blobs(
426
+ office=office,
427
+ blob_id_like=blob_id_like,
428
+ columns=columns,
429
+ sort_by=sort_by,
430
+ ascending=not desc,
431
+ limit=limit,
432
+ )
433
+ if to_csv:
434
+ df.to_csv(to_csv, index=False)
435
+ logging.info(f"Wrote {len(df)} rows to {to_csv}")
436
+ else:
437
+ # Friendly console preview
438
+ with pd.option_context("display.max_rows", 500, "display.max_columns", None):
439
+ logging.info(df.to_string(index=False))