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.
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/PKG-INFO +1 -1
- cwms_cli-0.3.4/cwmscli/commands/blob.py +700 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/commands_cwms.py +49 -5
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/__init__.py +25 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/pyproject.toml +1 -1
- cwms_cli-0.3.3/cwmscli/commands/blob.py +0 -437
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/LICENSE +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/README.md +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/__init__.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/__main__.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/callbacks/__init__.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/.gitignore +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/README.md +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/__init__.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/__main__.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/config.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/doclinks.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/examples/complete_config.json +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/parser.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/data/.gitignore +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/data/sample_config.json +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/test_dateutils.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/test_expressions.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/test_fileio.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/tests/test_main.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/transform.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/utils/__init__.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/utils/dateutils.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/utils/expression.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/utils/fileio.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/utils/logging.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/csv2cwms/writer.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/commands/shef_critfile_import.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/README.md +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/__init__.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/__main__.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/location/location.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/location/location_ids.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/location/location_ids_bygroup.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/root.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/timeseries/timeseries.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/load/timeseries/timeseries_ids.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/requirements.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/usgs/__init__.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/usgs/__main__.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/usgs/getUSGS_ratings_cda.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/usgs/getusgs_cda.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/usgs/getusgs_measurements_cda.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/usgs/rating_ini_file_import.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/click_help.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/colors.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/deps.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/intervals.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/io.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/logging/__init__.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/logging/formatters.py +0 -0
- {cwms_cli-0.3.3 → cwms_cli-0.3.4}/cwmscli/utils/ssl_errors.py +0 -0
- {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
|
+
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))
|