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.
- cwms_cli-0.1.1/LICENSE +21 -0
- cwms_cli-0.1.1/PKG-INFO +40 -0
- cwms_cli-0.1.1/README.md +17 -0
- cwms_cli-0.1.1/cwmscli/__init__.py +12 -0
- cwms_cli-0.1.1/cwmscli/__main__.py +15 -0
- cwms_cli-0.1.1/cwmscli/callbacks/__init__.py +18 -0
- cwms_cli-0.1.1/cwmscli/commands/blob.py +439 -0
- cwms_cli-0.1.1/cwmscli/commands/commands_cwms.py +227 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/.gitignore +3 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/README.md +51 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/__init__.py +5 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/__main__.py +265 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/examples/complete_config.json +19 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/examples/hourly.json +243 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/examples/minutes.json +315 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/data/.gitignore +1 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +278 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +9 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/data/sample_config.json +45 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +35 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/test_dateutils.py +68 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/test_expressions.py +49 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/tests/test_fileio.py +43 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/utils/__init__.py +5 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/utils/dateutils.py +105 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/utils/expression.py +39 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/utils/fileio.py +26 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/utils/logging.py +80 -0
- cwms_cli-0.1.1/cwmscli/commands/csv2cwms/utils/terminal.py +45 -0
- cwms_cli-0.1.1/cwmscli/commands/shef_critfile_import.py +146 -0
- cwms_cli-0.1.1/cwmscli/requirements.py +25 -0
- cwms_cli-0.1.1/cwmscli/usgs/__init__.py +161 -0
- cwms_cli-0.1.1/cwmscli/usgs/getUSGS_ratings_cda.py +346 -0
- cwms_cli-0.1.1/cwmscli/usgs/getusgs_cda.py +345 -0
- cwms_cli-0.1.1/cwmscli/usgs/getusgs_measurements_cda.py +961 -0
- cwms_cli-0.1.1/cwmscli/usgs/rating_ini_file_import.py +130 -0
- cwms_cli-0.1.1/cwmscli/utils/__init__.py +68 -0
- cwms_cli-0.1.1/cwmscli/utils/deps.py +102 -0
- 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.
|
cwms_cli-0.1.1/PKG-INFO
ADDED
|
@@ -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
|
+
[](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
|
+
|
cwms_cli-0.1.1/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# cwms-cli
|
|
2
|
+
|
|
3
|
+
command line utilities used for Corps Water Management Systems (CWMS) processes
|
|
4
|
+
|
|
5
|
+
[](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))
|