Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
431c268
Fix type hints for Python 3.9.
DanielTOsborne Nov 24, 2025
371859e
Add CLOB support.
DanielTOsborne Nov 24, 2025
cc2c570
Bump actions/checkout from 5 to 6 (#74)
dependabot[bot] Nov 24, 2025
0eb74ac
Add missing api key import for shef crit (#71)
krowvin Nov 25, 2025
cf8a38f
Make PyPi test only run when needed (#76)
krowvin Dec 5, 2025
44affd1
Update README.md (#75)
krowvin Dec 5, 2025
c431739
Update README.md (#78)
krowvin Dec 5, 2025
1acd87e
Bump sigstore/gh-action-sigstore-python from 3.1.0 to 3.2.0 (#83)
dependabot[bot] Dec 9, 2025
6be9192
80 build tests to support python 39 (#84)
krowvin Dec 9, 2025
c45c7d0
Correct package lock (#85)
krowvin Dec 9, 2025
8736376
Apply style guide fixes via poetry
krowvin Jan 9, 2026
9d1d4c6
Merge branch 'main' into enhancements/clob
krowvin Jan 9, 2026
baac7c6
Bump actions/upload-artifact from 5 to 6 (#92)
dependabot[bot] Jan 12, 2026
43b7da8
Bump actions/cache from 4 to 5 (#91)
dependabot[bot] Jan 12, 2026
9fede35
Bump actions/download-artifact from 6 to 7 (#90)
dependabot[bot] Jan 12, 2026
06965cb
Add OS check for pip command (#88)
krowvin Jan 13, 2026
4c00d8a
27 create init script (#35)
krowvin Jan 14, 2026
d3c190f
Setup Initial Tests (#87)
krowvin Jan 14, 2026
28fd851
Bump version from 0.1.5 to 0.2.0 (#95)
Enovotny Jan 15, 2026
fb790b3
Bump version from 0.2.0 to 0.2.1 (#96)
Enovotny Jan 15, 2026
c9f9e23
Update days back for usgs (#102)
krowvin Jan 23, 2026
2355dc6
patch bump (#104)
krowvin Jan 23, 2026
08df037
Remove test mock directory (#105)
krowvin Jan 23, 2026
2c31ff2
Bump actions/checkout from 4 to 6 (#98)
dependabot[bot] Jan 27, 2026
ccec311
Bump actions/setup-python from 5 to 6 (#97)
dependabot[bot] Jan 27, 2026
44d9454
Bump psf/black from 24.2.0 to 26.1.0 (#106)
dependabot[bot] Feb 3, 2026
efb0b96
Wrap CLI in friendly Error Handler - SSL Update (#107)
krowvin Feb 11, 2026
e43f8d8
Fix documentation link in README (#116)
krowvin Feb 12, 2026
8d948af
Bump actions/setup-python from 5 to 6 (#119)
dependabot[bot] Feb 27, 2026
228f9f7
Logging improvements (#113)
krowvin Feb 27, 2026
ef508ba
Add colorization method to cwms-cli (#118)
krowvin Feb 27, 2026
4b07344
Remove explicit API key from download/list commands (#115)
krowvin Feb 27, 2026
aa13e50
Bump version from 0.2.2 to 0.3.2 (#121)
krowvin Feb 27, 2026
1a18893
Rename test-deploy.yml to test-deploy.yml.tmp (#123)
krowvin Feb 27, 2026
da2417f
Change to 0.3.0 from 0.3.2 (#122)
krowvin Feb 27, 2026
c2e36e4
Handle missing noargs (#126)
krowvin Feb 27, 2026
7d1f41a
Display version at the top of every command (#127)
krowvin Feb 27, 2026
474972c
Logging info error (#132)
krowvin Feb 27, 2026
d2cb527
Bump version from 0.3.0 to 0.3.1 (#135)
krowvin Mar 2, 2026
5d1749b
Merge upstream/main into enhancements/clob
krowvin Mar 28, 2026
5af34d1
Refine clob command parity and tests
krowvin Mar 28, 2026
e97466b
Add blob vs clob docs
krowvin Mar 28, 2026
3b84861
Ensure clob handles / in path with ignored URL and add this to docs
krowvin Mar 28, 2026
b7c4eff
Merge branch 'main' into enhancements/clob
krowvin Mar 31, 2026
e65c4f6
Merge remote-tracking branch 'upstream/main' into enhancements/clob
krowvin Apr 9, 2026
09bc120
Add --limit to clob per the blob PR
krowvin Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cwmscli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def cli(
cli.add_command(commands_cwms.csv2cwms_cmd)
cli.add_command(commands_cwms.update_cli_cmd)
cli.add_command(commands_cwms.blob_group)
cli.add_command(commands_cwms.clob_group)
cli.add_command(commands_cwms.users_group)
cli.add_command(load.load_group)
add_version_to_help_tree(cli)
Expand Down
10 changes: 9 additions & 1 deletion cwmscli/commands/blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -722,4 +722,12 @@ def list_cmd(
else:
# Friendly console preview
with pd.option_context("display.max_rows", 500, "display.max_columns", None):
logging.info(df.to_string(index=False))
# Left-align all columns
logging.info(
"\n"
+ df.apply(
lambda s: (s := s.astype(str).str.strip()).str.ljust(
s.str.len().max()
)
).to_string(index=False, justify="left")
)
340 changes: 340 additions & 0 deletions cwmscli/commands/clob.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
import json
import logging
import os
import sys
from typing import Optional, Sequence

import cwms
import pandas as pd
import requests
from cwms import api as cwms_api

from cwmscli.utils import get_api_key, has_invalid_chars, log_scoped_read_hint


def _join_api_url(api_root: str, path: str) -> str:
return f"{api_root.rstrip('/')}/{path.lstrip('/')}"


def _resolve_optional_api_key(api_key: Optional[str], anonymous: bool) -> Optional[str]:
if anonymous or not api_key:
return None
return get_api_key(api_key, None)


def _write_clob_content(content: str, dest: str) -> str:
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
with open(dest, "w", encoding="utf-8", newline="") as f:
f.write(content)
return dest


def _clob_endpoint_id(clob_id: str) -> tuple[str, Optional[str]]:
normalized = clob_id.upper()
if has_invalid_chars(normalized):
return "ignored", normalized
return normalized, None


def _get_special_clob_text(*, office: str, clob_id: str) -> str:
with cwms_api.SESSION.get(
"clobs/ignored",
params={"office": office, "clob-id": clob_id},
headers={"Accept": "text/plain"},
) as response:
response.raise_for_status()
return response.text


def list_clobs(
office: Optional[str] = None,
clob_id_like: Optional[str] = None,
columns: Optional[Sequence[str]] = None,
sort_by: Optional[Sequence[str]] = None,
ascending: bool = True,
limit: Optional[int] = None,
page_size: Optional[int] = None,
) -> pd.DataFrame:
logging.info(f"Listing clobs for office: {office!r}...")
fetch_page_size = page_size if page_size is not None else limit
result = cwms.get_clobs(
office_id=office,
clob_id_like=clob_id_like,
page_size=fetch_page_size,
)

# Accept either a DataFrame or a JSON/dict-like response
if isinstance(result, pd.DataFrame):
df = result.copy()
else:
# Expecting normal clob return structure
data = getattr(result, "json", None)
if callable(data):
data = result.json()
df = pd.DataFrame((data or {}).get("clobs", []))

# Allow column filtering
if columns:
keep = [c for c in columns if c in df.columns]
if keep:
df = df[keep]

# Sort by option
if sort_by:
by = [c for c in sort_by if c in df.columns]
if by:
df = df.sort_values(by=by, ascending=ascending, kind="stable")

# Optional limit
if limit is not None:
df = df.head(limit)

logging.info(f"Found {len(df):,} clob(s)")
# List the clobs in the logger
for _, row in df.iterrows():
logging.info(f"clob ID: {row['id']}, Description: {row.get('description')}")
return df


def upload_cmd(
input_file: str,
clob_id: str,
description: str,
overwrite: bool,
dry_run: bool,
office: str,
api_root: str,
api_key: str,
):
cwms.init_session(api_root=api_root, api_key=get_api_key(api_key, None))
try:
file_size = os.path.getsize(input_file)
with open(input_file, "r", encoding="utf-8") as f:
file_data = f.read()
logging.info(f"Read file: {input_file} ({file_size} bytes)")
except Exception as e:
logging.error(f"Failed to read file: {e}")
sys.exit(1)

clob_id_up = clob_id.upper()
logging.debug(f"Office={office} clobID={clob_id_up}")

clob = {
"office-id": office,
"id": clob_id_up,
"description": (
json.dumps(description)
if isinstance(description, (dict, list))
else description
),
"value": file_data,
}
params = {"fail-if-exists": not overwrite}
view_url = _join_api_url(api_root, f"clobs/{clob_id_up}?office={office}")

if dry_run:
logging.info(
f"DRY RUN: would POST {_join_api_url(api_root, 'clobs')} with params={params}"
)
logging.info(
json.dumps(
{
"url": _join_api_url(api_root, "clobs"),
"params": params,
"clob": {**clob, "value": f'<{len(clob["value"])} chars>'},
},
indent=2,
)
)
return

try:
cwms.store_clobs(clob, fail_if_exists=not overwrite)
logging.info(f"Uploaded clob: {clob_id_up}")
if has_invalid_chars(clob_id_up):
logging.info(
f"View: {_join_api_url(api_root, f'clobs/ignored?clob-id={clob_id_up}&office={office}')}"
)
else:
logging.info(f"View: {view_url}")
except requests.HTTPError as e:
detail = getattr(e.response, "text", "") or str(e)
logging.error(f"Failed to upload (HTTP): {detail}")
sys.exit(1)
except Exception as e:
logging.error(f"Failed to upload: {e}")
sys.exit(1)


def download_cmd(
clob_id: str,
dest: str,
office: str,
api_root: str,
api_key: str,
dry_run: bool,
anonymous: bool = False,
):
if dry_run:
logging.info(
f"DRY RUN: would GET {api_root} clob with clob-id={clob_id} office={office}."
)
return
resolved_api_key = _resolve_optional_api_key(api_key, anonymous)
cwms.init_session(api_root=api_root, api_key=resolved_api_key)
bid = clob_id.upper()
logging.debug(f"Office={office} clobID={bid}")

try:
path_id, query_id = _clob_endpoint_id(bid)
if query_id is None:
clob = cwms.get_clob(office_id=office, clob_id=path_id)
payload = getattr(clob, "json", clob)
if callable(payload):
payload = payload()
if isinstance(payload, dict):
content = payload.get("value", "")
else:
content = str(payload)
else:
content = _get_special_clob_text(office=office, clob_id=query_id)
target = dest or bid
_write_clob_content(content, target)
logging.info(f"Downloaded clob to: {target}")
except requests.HTTPError as e:
detail = getattr(e.response, "text", "") or str(e)
logging.error(f"Failed to download (HTTP): {detail}")
log_scoped_read_hint(
api_key=resolved_api_key,
anonymous=anonymous,
office=office,
action="download",
resource="clob content",
)
sys.exit(1)
except Exception as e:
logging.error(f"Failed to download: {e}")
log_scoped_read_hint(
api_key=resolved_api_key,
anonymous=anonymous,
office=office,
action="download",
resource="clob content",
)
sys.exit(1)


def delete_cmd(clob_id: str, office: str, api_root: str, api_key: str, dry_run: bool):

if dry_run:
logging.info(
f"DRY RUN: would DELETE {api_root} clob with clob-id={clob_id} office={office}"
)
return
cwms.init_session(api_root=api_root, api_key=get_api_key(api_key, None))
cid = clob_id.upper()
path_id, query_id = _clob_endpoint_id(cid)
if query_id is None:
cwms.delete_clob(office_id=office, clob_id=cid)
else:
cwms_api.delete(
f"clobs/{path_id}", params={"office": office, "clob-id": query_id}
)
logging.info(f"Deleted clob: {clob_id} for office: {office}")


def update_cmd(
input_file: str,
clob_id: str,
description: str,
ignore_nulls: bool,
dry_run: bool,
office: str,
api_root: str,
api_key: str,
):
if dry_run:
logging.info(
f"DRY RUN: would PATCH {api_root} clob with clob-id={clob_id} office={office}"
)
return
file_data = None
if input_file:
try:
file_size = os.path.getsize(input_file)
with open(input_file, "r", encoding="utf-8") as f:
file_data = f.read()
logging.info(f"Read file: {input_file} ({file_size} bytes)")
except Exception as e:
logging.error(f"Failed to read file: {e}")
sys.exit(1)
# Setup minimum required payload
clob = {"office-id": office, "id": clob_id.upper()}
if description:
clob["description"] = description

if file_data:
clob["value"] = file_data
cwms.init_session(api_root=api_root, api_key=get_api_key(api_key, None))
cid = clob_id.upper()
path_id, query_id = _clob_endpoint_id(cid)
if query_id is None:
cwms.update_clob(clob, cid, ignore_nulls=ignore_nulls)
else:
cwms_api.patch(
f"clobs/{path_id}",
data=clob,
params={"clob-id": query_id, "ignore-nulls": ignore_nulls},
)
logging.info(f"Updated clob: {clob_id} for office: {office}")


def list_cmd(
clob_id_like: str,
columns: list[str],
sort_by: list[str],
desc: bool,
limit: int,
page_size: int,
to_csv: str,
office: str,
api_root: str,
api_key: str,
anonymous: bool = False,
):
resolved_api_key = _resolve_optional_api_key(api_key, anonymous)
cwms.init_session(api_root=api_root, api_key=resolved_api_key)
try:
df = list_clobs(
office=office,
clob_id_like=clob_id_like,
columns=columns,
sort_by=sort_by,
ascending=not desc,
limit=limit,
page_size=page_size,
)
except Exception:
log_scoped_read_hint(
api_key=resolved_api_key,
anonymous=anonymous,
office=office,
action="list",
resource="clob content",
)
raise
if to_csv:
df.to_csv(to_csv, index=False)
logging.info(f"Wrote {len(df)} rows to {to_csv}")
else:
# Friendly console preview
with pd.option_context("display.max_rows", 500, "display.max_columns", None):
# Left-align all columns
logging.info(
"\n"
+ df.apply(
lambda s: (s := s.astype(str).str.strip()).str.ljust(
s.str.len().max()
)
).to_string(index=False, justify="left")
)
Loading
Loading