cwms-cli 0.7.4__tar.gz → 0.8.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.7.4 → cwms_cli-0.8.1}/PKG-INFO +32 -2
- cwms_cli-0.8.1/README.md +68 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/__main__.py +49 -8
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/_generated/ownership_data.py +11 -1
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/blob.py +37 -20
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/clob.py +44 -16
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/commands_cwms.py +67 -11
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/writer.py +4 -0
- cwms_cli-0.8.1/cwmscli/commands/env.py +543 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/shef/import_critfile.py +3 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/shef/import_infile.py +8 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/users.py +13 -0
- cwms_cli-0.8.1/cwmscli/dss/__init__.py +5 -0
- cwms_cli-0.8.1/cwmscli/dss/cli.py +352 -0
- cwms_cli-0.8.1/cwmscli/dss/naming.py +318 -0
- cwms_cli-0.8.1/cwmscli/dss/transfer.py +218 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/location/location_ids.py +11 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/location/location_ids_bygroup.py +3 -0
- cwms_cli-0.8.1/cwmscli/load/root.py +335 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/timeseries/timeseries.py +3 -2
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/timeseries/timeseries_data.py +161 -157
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/timeseries/timeseries_ids.py +3 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/requirements.py +16 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/usgs/getUSGS_ratings_cda.py +9 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/usgs/getusgs_cda.py +10 -1
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/usgs/getusgs_measurements_cda.py +44 -36
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/usgs/rating_ini_file_import.py +4 -1
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/__init__.py +1 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/auth.py +7 -8
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/click_help.py +29 -24
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/deps.py +3 -2
- cwms_cli-0.8.1/cwmscli/utils/env_store.py +133 -0
- cwms_cli-0.8.1/cwmscli/utils/friendly_errors.py +356 -0
- cwms_cli-0.8.1/cwmscli/utils/interaction.py +45 -0
- cwms_cli-0.8.1/cwmscli/utils/links.py +5 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/logging/__init__.py +1 -1
- cwms_cli-0.8.1/cwmscli/utils/paths.py +23 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/ssl_errors.py +14 -2
- cwms_cli-0.8.1/cwmscli/utils/update.py +155 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/version_cli.py +7 -1
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/pyproject.toml +8 -5
- cwms_cli-0.7.4/README.md +0 -42
- cwms_cli-0.7.4/cwmscli/load/root.py +0 -190
- cwms_cli-0.7.4/cwmscli/utils/friendly_errors.py +0 -147
- cwms_cli-0.7.4/cwmscli/utils/links.py +0 -1
- cwms_cli-0.7.4/cwmscli/utils/update.py +0 -61
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/LICENSE +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/__init__.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/_generated/__init__.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/callbacks/__init__.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/.gitignore +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/README.md +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/__init__.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/__main__.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/config.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/doclinks.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/examples/complete_config.json +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/parser.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/data/.gitignore +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/data/sample_config.json +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/test_dateutils.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/test_expressions.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/test_fileio.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/tests/test_main.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/transform.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/utils/__init__.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/utils/dateutils.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/utils/expression.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/utils/fileio.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/csv2cwms/utils/logging.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/shef/__init__.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/commands/shef/shef_parameters.csv +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/README.md +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/__init__.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/__main__.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/load/location/location.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/ownership.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/usgs/__init__.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/usgs/__main__.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/callback_success.html +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/colors.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/intervals.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/io.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/cwmscli/utils/logging/formatters.py +0 -0
- {cwms_cli-0.7.4 → cwms_cli-0.8.1}/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
|
+
Version: 0.8.1
|
|
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
|
|
@@ -16,8 +16,12 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Provides-Extra: dss
|
|
19
20
|
Requires-Dist: click (>=8.1.8,<9.0.0)
|
|
20
21
|
Requires-Dist: colorama (>=0.4.6,<0.5.0)
|
|
22
|
+
Requires-Dist: cwms-python (>=1.0.7) ; extra == "dss"
|
|
23
|
+
Requires-Dist: hec-python-library (>=0.9.5) ; extra == "dss"
|
|
24
|
+
Requires-Dist: hecdss (>=0.1.24) ; extra == "dss"
|
|
21
25
|
Requires-Dist: requests (>=2.32.0,<3.0.0) ; python_version == "3.9"
|
|
22
26
|
Requires-Dist: requests (>=2.33.0,<3.0.0) ; python_version >= "3.10"
|
|
23
27
|
Project-URL: Repository, https://github.com/HydrologicEngineeringCenter/cwms-cli
|
|
@@ -39,7 +43,7 @@ Note: You may need to run `python -m pip install cwms-cli` if PIP is not in your
|
|
|
39
43
|
|
|
40
44
|
### Update
|
|
41
45
|
```sh
|
|
42
|
-
pip install cwms-cli
|
|
46
|
+
python -m pip install --upgrade cwms-cli
|
|
43
47
|
```
|
|
44
48
|
|
|
45
49
|
Or as of version `0.3.0+`
|
|
@@ -47,6 +51,12 @@ Or as of version `0.3.0+`
|
|
|
47
51
|
cwms-cli update
|
|
48
52
|
```
|
|
49
53
|
|
|
54
|
+
`cwms-cli update` displays the Python executable, environment, package metadata
|
|
55
|
+
location, and editable project location when applicable before asking for
|
|
56
|
+
confirmation. It runs pip through the same Python interpreter that is running
|
|
57
|
+
`cwms-cli`, avoiding accidental updates to a different Python installation or
|
|
58
|
+
virtual environment.
|
|
59
|
+
|
|
50
60
|
To install a specific version:
|
|
51
61
|
```sh
|
|
52
62
|
cwms-cli update --target-version 0.7.1 --yes
|
|
@@ -66,3 +76,23 @@ from cwmscli.usgs.getusgs_measurements_cda import getusgs_measurements_cda
|
|
|
66
76
|
from cwmscli.usgs.getUSGS_ratings_cda import getusgs_rating_cda
|
|
67
77
|
```
|
|
68
78
|
|
|
79
|
+
## Development environment
|
|
80
|
+
|
|
81
|
+
The repository includes a Linux/Python 3.12 development container exercised by
|
|
82
|
+
a dedicated CI job. Open the repository with the VS Code Dev Containers
|
|
83
|
+
extension, or use the Dev Container CLI:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
devcontainer up --workspace-folder .
|
|
87
|
+
devcontainer exec --workspace-folder . poetry run pytest -q
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The container installs project dependencies with Poetry and uses
|
|
91
|
+
`/home/vscode/.venv`, so it does not reuse a host operating system's `.venv`.
|
|
92
|
+
Poetry itself is kept in a separate `/opt/poetry` environment. The workspace
|
|
93
|
+
root is also added to `PYTHONPATH`, so the checked-out source and local test
|
|
94
|
+
modules are available throughout a Codespace without copying them into the
|
|
95
|
+
image. Use the container for changes involving time zones, paths, native
|
|
96
|
+
libraries, or other operating-system-dependent behavior. Standard Python 3.9
|
|
97
|
+
and Python 3.12 installations remain covered by the existing CI matrix.
|
|
98
|
+
|
cwms_cli-0.8.1/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# cwms-cli
|
|
2
|
+
|
|
3
|
+
A collection of scripts to create, read, update, list, and delete data through CWMS Data API (CDA) and other commonly used API in the US Army Corps of Engineers water management. CWMS-CLI wraps these API in a friendly to use terminal based interface.
|
|
4
|
+
|
|
5
|
+
[](https://cwms-cli.readthedocs.io/en/latest/cli.html#cwms-cli) - 📖 Read the docs: https://cwms-cli.readthedocs.io/en/latest/
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install cwms-cli
|
|
11
|
+
```
|
|
12
|
+
Note: You may need to run `python -m pip install cwms-cli` if PIP is not in your path.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Update
|
|
16
|
+
```sh
|
|
17
|
+
python -m pip install --upgrade cwms-cli
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or as of version `0.3.0+`
|
|
21
|
+
```sh
|
|
22
|
+
cwms-cli update
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`cwms-cli update` displays the Python executable, environment, package metadata
|
|
26
|
+
location, and editable project location when applicable before asking for
|
|
27
|
+
confirmation. It runs pip through the same Python interpreter that is running
|
|
28
|
+
`cwms-cli`, avoiding accidental updates to a different Python installation or
|
|
29
|
+
virtual environment.
|
|
30
|
+
|
|
31
|
+
To install a specific version:
|
|
32
|
+
```sh
|
|
33
|
+
cwms-cli update --target-version 0.7.1 --yes
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Command line implementation
|
|
37
|
+
|
|
38
|
+
View the help in terminal:
|
|
39
|
+
```sh
|
|
40
|
+
cwms-cli --help
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## run from within python
|
|
44
|
+
```python
|
|
45
|
+
from cwmscli.usgs.getusgs_cda import getusgs_cda
|
|
46
|
+
from cwmscli.usgs.getusgs_measurements_cda import getusgs_measurements_cda
|
|
47
|
+
from cwmscli.usgs.getUSGS_ratings_cda import getusgs_rating_cda
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Development environment
|
|
51
|
+
|
|
52
|
+
The repository includes a Linux/Python 3.12 development container exercised by
|
|
53
|
+
a dedicated CI job. Open the repository with the VS Code Dev Containers
|
|
54
|
+
extension, or use the Dev Container CLI:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
devcontainer up --workspace-folder .
|
|
58
|
+
devcontainer exec --workspace-folder . poetry run pytest -q
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The container installs project dependencies with Poetry and uses
|
|
62
|
+
`/home/vscode/.venv`, so it does not reuse a host operating system's `.venv`.
|
|
63
|
+
Poetry itself is kept in a separate `/opt/poetry` environment. The workspace
|
|
64
|
+
root is also added to `PYTHONPATH`, so the checked-out source and local test
|
|
65
|
+
modules are available throughout a Codespace without copying them into the
|
|
66
|
+
image. Use the container for changes involving time zones, paths, native
|
|
67
|
+
libraries, or other operating-system-dependent behavior. Standard Python 3.9
|
|
68
|
+
and Python 3.12 installations remain covered by the existing CI matrix.
|
|
@@ -7,10 +7,17 @@ import click
|
|
|
7
7
|
from click.core import ParameterSource
|
|
8
8
|
|
|
9
9
|
from cwmscli.commands import commands_cwms
|
|
10
|
+
from cwmscli.commands.env import env_group
|
|
11
|
+
from cwmscli.dss import dss_group
|
|
10
12
|
from cwmscli.load import __main__ as load
|
|
11
13
|
from cwmscli.usgs import usgs_group
|
|
12
14
|
from cwmscli.utils.click_help import add_version_to_help_tree
|
|
13
|
-
from cwmscli.utils.friendly_errors import
|
|
15
|
+
from cwmscli.utils.friendly_errors import (
|
|
16
|
+
cda_stack_trace,
|
|
17
|
+
format_cda_stack_trace,
|
|
18
|
+
to_user_facing_error,
|
|
19
|
+
)
|
|
20
|
+
from cwmscli.utils.links import BUG_REPORT_URL
|
|
14
21
|
from cwmscli.utils.logging import (
|
|
15
22
|
LoggingConfig,
|
|
16
23
|
apply_logging_policies,
|
|
@@ -57,6 +64,15 @@ from cwmscli.utils.version_cli import show_version_and_exit
|
|
|
57
64
|
default=False,
|
|
58
65
|
help="Suppress routine output; warnings and errors still print.",
|
|
59
66
|
)
|
|
67
|
+
@click.option(
|
|
68
|
+
"--non-interactive/--interactive",
|
|
69
|
+
default=None,
|
|
70
|
+
envvar="CWMS_CLI_NON_INTERACTIVE",
|
|
71
|
+
help=(
|
|
72
|
+
"Disable prompts. Automatically enabled when standard input is not a "
|
|
73
|
+
"terminal or a CI environment is detected."
|
|
74
|
+
),
|
|
75
|
+
)
|
|
60
76
|
@click.pass_context
|
|
61
77
|
def cli(
|
|
62
78
|
ctx: click.Context,
|
|
@@ -64,6 +80,7 @@ def cli(
|
|
|
64
80
|
no_color: bool,
|
|
65
81
|
log_level: str,
|
|
66
82
|
quiet: bool,
|
|
83
|
+
non_interactive: Optional[bool],
|
|
67
84
|
) -> None:
|
|
68
85
|
level = getattr(logging, log_level.upper(), logging.INFO)
|
|
69
86
|
level = apply_logging_policies(
|
|
@@ -76,7 +93,7 @@ def cli(
|
|
|
76
93
|
|
|
77
94
|
# Disable colors if stdout isn't a TTY (piped/redirected)
|
|
78
95
|
tty = sys.stdout.isatty()
|
|
79
|
-
color = (not no_color) and tty
|
|
96
|
+
color = (not no_color) and ("NO_COLOR" not in os.environ) and tty
|
|
80
97
|
setup_logging(LoggingConfig(level=level, log_file=log_file, color=color))
|
|
81
98
|
|
|
82
99
|
|
|
@@ -88,7 +105,9 @@ cli.add_command(commands_cwms.update_cli_cmd)
|
|
|
88
105
|
cli.add_command(commands_cwms.blob_group)
|
|
89
106
|
cli.add_command(commands_cwms.clob_group)
|
|
90
107
|
cli.add_command(commands_cwms.users_group)
|
|
108
|
+
cli.add_command(env_group)
|
|
91
109
|
cli.add_command(load.load_group)
|
|
110
|
+
cli.add_command(dss_group)
|
|
92
111
|
add_version_to_help_tree(cli)
|
|
93
112
|
|
|
94
113
|
|
|
@@ -111,25 +130,47 @@ def main() -> None:
|
|
|
111
130
|
except SystemExit:
|
|
112
131
|
raise
|
|
113
132
|
except click.ClickException as e:
|
|
133
|
+
debug = debug or logging.getLogger().isEnabledFor(logging.DEBUG)
|
|
134
|
+
if debug:
|
|
135
|
+
server_stack_trace = cda_stack_trace(e)
|
|
136
|
+
if server_stack_trace is not None:
|
|
137
|
+
click.echo(format_cda_stack_trace(server_stack_trace), err=True)
|
|
138
|
+
raise SystemExit(e.exit_code)
|
|
139
|
+
else:
|
|
140
|
+
if is_cert_verify_error(e):
|
|
141
|
+
click.echo(ssl_help_text(), err=True)
|
|
142
|
+
raise SystemExit(2)
|
|
143
|
+
friendly_error = to_user_facing_error(e)
|
|
144
|
+
if friendly_error is not None:
|
|
145
|
+
logging.debug("Suppressed traceback for CLI exception", exc_info=e)
|
|
146
|
+
friendly_error.show()
|
|
147
|
+
raise SystemExit(friendly_error.exit_code)
|
|
114
148
|
e.show()
|
|
115
149
|
raise SystemExit(e.exit_code)
|
|
116
150
|
except Exception as e:
|
|
151
|
+
# The environment switch supports failures before Click configures logging.
|
|
152
|
+
# Once CLI setup has run, --log-level DEBUG enables the same behavior.
|
|
153
|
+
debug = debug or logging.getLogger().isEnabledFor(logging.DEBUG)
|
|
154
|
+
|
|
117
155
|
if is_cert_verify_error(e) and not debug:
|
|
118
|
-
# Keep this short, no stack trace.
|
|
119
|
-
logging.error(
|
|
120
|
-
"SSL certificate verification failed while connecting to the server."
|
|
121
|
-
)
|
|
122
156
|
click.echo(ssl_help_text(), err=True)
|
|
123
157
|
raise SystemExit(2)
|
|
124
158
|
|
|
125
|
-
if
|
|
159
|
+
if debug:
|
|
160
|
+
server_stack_trace = cda_stack_trace(e)
|
|
161
|
+
if server_stack_trace is not None:
|
|
162
|
+
click.echo(format_cda_stack_trace(server_stack_trace), err=True)
|
|
163
|
+
raise SystemExit(1)
|
|
164
|
+
else:
|
|
126
165
|
friendly_error = to_user_facing_error(e)
|
|
127
166
|
if friendly_error is not None:
|
|
128
167
|
logging.debug("Suppressed traceback for CLI exception", exc_info=e)
|
|
129
168
|
friendly_error.show()
|
|
130
169
|
raise SystemExit(friendly_error.exit_code)
|
|
131
170
|
|
|
132
|
-
|
|
171
|
+
click.echo(f"Unexpected error. Report it at {BUG_REPORT_URL}", err=True)
|
|
172
|
+
# Preserve raw exception behavior when CDA did not provide a server stack
|
|
173
|
+
# trace so an issue report includes useful diagnostic details.
|
|
133
174
|
raise
|
|
134
175
|
|
|
135
176
|
|
|
@@ -15,6 +15,16 @@ OWNERSHIP_DATA = {
|
|
|
15
15
|
"name": "Charles Graham"
|
|
16
16
|
}
|
|
17
17
|
],
|
|
18
|
+
"cwms-cli env": [
|
|
19
|
+
{
|
|
20
|
+
"email": "mitchell.s.weier@usace.army.mil",
|
|
21
|
+
"name": "Mitch Weier"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"email": "charles.r.graham@usace.army.mil",
|
|
25
|
+
"name": "Charles Graham"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
18
28
|
"cwms-cli load": [
|
|
19
29
|
{
|
|
20
30
|
"email": "charles.r.graham@usace.army.mil",
|
|
@@ -25,7 +35,7 @@ OWNERSHIP_DATA = {
|
|
|
25
35
|
"name": "Eric Novotny"
|
|
26
36
|
}
|
|
27
37
|
],
|
|
28
|
-
"cwms-cli
|
|
38
|
+
"cwms-cli shef": [
|
|
29
39
|
{
|
|
30
40
|
"email": "eric.v.novotny@usace.army.mil",
|
|
31
41
|
"name": "Eric Novotny"
|
|
@@ -20,6 +20,10 @@ from cwmscli.utils import (
|
|
|
20
20
|
)
|
|
21
21
|
from cwmscli.utils.click_help import DOCS_BASE_URL
|
|
22
22
|
from cwmscli.utils.deps import requires
|
|
23
|
+
from cwmscli.utils.friendly_errors import (
|
|
24
|
+
is_actionable_service_error,
|
|
25
|
+
is_fatal_service_error,
|
|
26
|
+
)
|
|
23
27
|
|
|
24
28
|
# used to rebuild data URL for images
|
|
25
29
|
DATA_URL_RE = re.compile(r"^data:(?P<mime>[^;]+);base64,(?P<data>.+)$", re.I | re.S)
|
|
@@ -122,7 +126,9 @@ def _default_download_dest(blob_id: str) -> str:
|
|
|
122
126
|
def _blob_media_type(cwms_module, office: str, blob_id: str) -> Optional[str]:
|
|
123
127
|
try:
|
|
124
128
|
result = cwms_module.get_blobs(office_id=office, blob_id_like=blob_id)
|
|
125
|
-
except Exception:
|
|
129
|
+
except Exception as error:
|
|
130
|
+
if is_fatal_service_error(error):
|
|
131
|
+
raise
|
|
126
132
|
return None
|
|
127
133
|
|
|
128
134
|
df = getattr(result, "df", result)
|
|
@@ -222,12 +228,11 @@ def store_blob(**kwargs):
|
|
|
222
228
|
cwms.store_blobs(blob, fail_if_exists=kwargs.get("overwrite"))
|
|
223
229
|
logging.info(f"Successfully stored blob with ID: {blob_id}")
|
|
224
230
|
logging.info(f"View: {view_url}")
|
|
225
|
-
except requests.HTTPError
|
|
226
|
-
|
|
227
|
-
detail = getattr(e.response, "text", "") or str(e)
|
|
228
|
-
logging.error(f"Failed to store blob (HTTP): {detail}")
|
|
229
|
-
sys.exit(1)
|
|
231
|
+
except requests.HTTPError:
|
|
232
|
+
raise
|
|
230
233
|
except Exception as e:
|
|
234
|
+
if is_actionable_service_error(e):
|
|
235
|
+
raise
|
|
231
236
|
logging.error(f"Failed to store blob: {e}")
|
|
232
237
|
sys.exit(1)
|
|
233
238
|
|
|
@@ -257,11 +262,11 @@ def retrieve_blob(**kwargs):
|
|
|
257
262
|
media_type_hint=_blob_media_type(cwms, kwargs.get("office"), blob_id),
|
|
258
263
|
)
|
|
259
264
|
logging.info(f"Downloaded blob to: {blob_id}")
|
|
260
|
-
except requests.HTTPError
|
|
261
|
-
|
|
262
|
-
logging.error(f"Failed to retrieve blob (HTTP): {detail}")
|
|
263
|
-
sys.exit(1)
|
|
265
|
+
except requests.HTTPError:
|
|
266
|
+
raise
|
|
264
267
|
except Exception as e:
|
|
268
|
+
if is_actionable_service_error(e):
|
|
269
|
+
raise
|
|
265
270
|
logging.error(f"Failed to retrieve blob: {e}")
|
|
266
271
|
sys.exit(1)
|
|
267
272
|
|
|
@@ -279,11 +284,11 @@ def delete_blob(**kwargs):
|
|
|
279
284
|
blob_id=kwargs.get("blob_id").upper(),
|
|
280
285
|
)
|
|
281
286
|
logging.info(f"Successfully deleted blob with ID: {blob_id}")
|
|
282
|
-
except requests.HTTPError
|
|
283
|
-
|
|
284
|
-
logging.error(f"Failed to delete blob (HTTP): {details}")
|
|
285
|
-
sys.exit(1)
|
|
287
|
+
except requests.HTTPError:
|
|
288
|
+
raise
|
|
286
289
|
except Exception as e:
|
|
290
|
+
if is_actionable_service_error(e):
|
|
291
|
+
raise
|
|
287
292
|
logging.error(f"Failed to delete blob: {e}")
|
|
288
293
|
sys.exit(1)
|
|
289
294
|
|
|
@@ -550,6 +555,8 @@ def upload_cmd(
|
|
|
550
555
|
)
|
|
551
556
|
logging.info(f"View: {view_url}")
|
|
552
557
|
except requests.HTTPError as e:
|
|
558
|
+
if is_fatal_service_error(e):
|
|
559
|
+
raise
|
|
553
560
|
failures += 1
|
|
554
561
|
detail = getattr(e.response, "text", "") or str(e)
|
|
555
562
|
logging.error(
|
|
@@ -560,6 +567,8 @@ def upload_cmd(
|
|
|
560
567
|
)
|
|
561
568
|
)
|
|
562
569
|
except Exception as e:
|
|
570
|
+
if is_fatal_service_error(e):
|
|
571
|
+
raise
|
|
563
572
|
failures += 1
|
|
564
573
|
logging.error(
|
|
565
574
|
colors.c(
|
|
@@ -613,15 +622,13 @@ def download_cmd(
|
|
|
613
622
|
try:
|
|
614
623
|
blob_content = cwms.get_blob(office_id=office, blob_id=bid)
|
|
615
624
|
target = dest or _default_download_dest(bid)
|
|
616
|
-
_save_blob_content(
|
|
625
|
+
saved_target = _save_blob_content(
|
|
617
626
|
blob_content,
|
|
618
627
|
dest=target,
|
|
619
628
|
media_type_hint=_blob_media_type(cwms, office, bid),
|
|
620
629
|
)
|
|
621
|
-
logging.info(f"Downloaded blob to: {
|
|
622
|
-
except requests.HTTPError
|
|
623
|
-
detail = getattr(e.response, "text", "") or str(e)
|
|
624
|
-
logging.error(f"Failed to download (HTTP): {detail}")
|
|
630
|
+
logging.info(f"Downloaded blob to: {saved_target}")
|
|
631
|
+
except requests.HTTPError:
|
|
625
632
|
log_scoped_read_hint(
|
|
626
633
|
credential_kind=credential_kind,
|
|
627
634
|
anonymous=anonymous,
|
|
@@ -629,9 +636,19 @@ def download_cmd(
|
|
|
629
636
|
action="download",
|
|
630
637
|
resource="blob content",
|
|
631
638
|
)
|
|
632
|
-
|
|
639
|
+
raise
|
|
633
640
|
except Exception as e:
|
|
641
|
+
if is_actionable_service_error(e):
|
|
642
|
+
log_scoped_read_hint(
|
|
643
|
+
credential_kind=credential_kind,
|
|
644
|
+
anonymous=anonymous,
|
|
645
|
+
office=office,
|
|
646
|
+
action="download",
|
|
647
|
+
resource="blob content",
|
|
648
|
+
)
|
|
649
|
+
raise
|
|
634
650
|
logging.error(format_local_download_error(e, BLOB_DOCS_URL))
|
|
651
|
+
# Local write/path failures are not CDA credential scope problems.
|
|
635
652
|
if not isinstance(e, (OSError, ValueError)):
|
|
636
653
|
log_scoped_read_hint(
|
|
637
654
|
credential_kind=credential_kind,
|
|
@@ -12,10 +12,16 @@ from cwms import api as cwms_api
|
|
|
12
12
|
from cwmscli.utils import (
|
|
13
13
|
format_local_download_error,
|
|
14
14
|
get_api_key,
|
|
15
|
+
get_saved_login_token,
|
|
15
16
|
has_invalid_chars,
|
|
17
|
+
init_cwms_session,
|
|
16
18
|
log_scoped_read_hint,
|
|
17
19
|
validate_default_download_dest,
|
|
18
20
|
)
|
|
21
|
+
from cwmscli.utils.click_help import DOCS_BASE_URL
|
|
22
|
+
from cwmscli.utils.friendly_errors import is_actionable_service_error
|
|
23
|
+
|
|
24
|
+
CLOB_DOCS_URL = f"{DOCS_BASE_URL}/cli/clob.html"
|
|
19
25
|
|
|
20
26
|
|
|
21
27
|
def _join_api_url(api_root: str, path: str) -> str:
|
|
@@ -28,6 +34,16 @@ def _resolve_optional_api_key(api_key: Optional[str], anonymous: bool) -> Option
|
|
|
28
34
|
return get_api_key(api_key, None)
|
|
29
35
|
|
|
30
36
|
|
|
37
|
+
def _resolve_credential_kind(api_key: Optional[str], anonymous: bool) -> Optional[str]:
|
|
38
|
+
if anonymous:
|
|
39
|
+
return None
|
|
40
|
+
if get_saved_login_token():
|
|
41
|
+
return "token"
|
|
42
|
+
if _resolve_optional_api_key(api_key, anonymous):
|
|
43
|
+
return "api_key"
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
31
47
|
def _write_clob_content(content: str, dest: str) -> str:
|
|
32
48
|
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
|
|
33
49
|
with open(dest, "w", encoding="utf-8", newline="") as f:
|
|
@@ -36,12 +52,17 @@ def _write_clob_content(content: str, dest: str) -> str:
|
|
|
36
52
|
|
|
37
53
|
|
|
38
54
|
def _default_download_dest(clob_id: str) -> str:
|
|
39
|
-
return validate_default_download_dest(
|
|
55
|
+
return validate_default_download_dest(
|
|
56
|
+
clob_id,
|
|
57
|
+
resource_name="Clob",
|
|
58
|
+
docs_url=CLOB_DOCS_URL,
|
|
59
|
+
)
|
|
40
60
|
|
|
41
61
|
|
|
42
62
|
def _clob_endpoint_id(clob_id: str) -> tuple[str, Optional[str]]:
|
|
43
63
|
normalized = clob_id.upper()
|
|
44
64
|
if has_invalid_chars(normalized):
|
|
65
|
+
# Path-like IDs use the placeholder route and keep the real ID in query params.
|
|
45
66
|
return "ignored", normalized
|
|
46
67
|
return normalized, None
|
|
47
68
|
|
|
@@ -167,11 +188,11 @@ def upload_cmd(
|
|
|
167
188
|
)
|
|
168
189
|
else:
|
|
169
190
|
logging.info(f"View: {view_url}")
|
|
170
|
-
except requests.HTTPError
|
|
171
|
-
|
|
172
|
-
logging.error(f"Failed to upload (HTTP): {detail}")
|
|
173
|
-
sys.exit(1)
|
|
191
|
+
except requests.HTTPError:
|
|
192
|
+
raise
|
|
174
193
|
except Exception as e:
|
|
194
|
+
if is_actionable_service_error(e):
|
|
195
|
+
raise
|
|
175
196
|
logging.error(f"Failed to upload: {e}")
|
|
176
197
|
sys.exit(1)
|
|
177
198
|
|
|
@@ -190,8 +211,8 @@ def download_cmd(
|
|
|
190
211
|
f"DRY RUN: would GET {api_root} clob with clob-id={clob_id} office={office}."
|
|
191
212
|
)
|
|
192
213
|
return
|
|
193
|
-
|
|
194
|
-
cwms
|
|
214
|
+
credential_kind = _resolve_credential_kind(api_key, anonymous)
|
|
215
|
+
init_cwms_session(cwms, api_root=api_root, api_key=api_key, anonymous=anonymous)
|
|
195
216
|
bid = clob_id.upper()
|
|
196
217
|
logging.debug(f"Office={office} clobID={bid}")
|
|
197
218
|
|
|
@@ -211,19 +232,26 @@ def download_cmd(
|
|
|
211
232
|
target = dest or _default_download_dest(bid)
|
|
212
233
|
_write_clob_content(content, target)
|
|
213
234
|
logging.info(f"Downloaded clob to: {target}")
|
|
214
|
-
except requests.HTTPError
|
|
215
|
-
detail = getattr(e.response, "text", "") or str(e)
|
|
216
|
-
logging.error(f"Failed to download (HTTP): {detail}")
|
|
235
|
+
except requests.HTTPError:
|
|
217
236
|
log_scoped_read_hint(
|
|
218
|
-
|
|
237
|
+
credential_kind=credential_kind,
|
|
219
238
|
anonymous=anonymous,
|
|
220
239
|
office=office,
|
|
221
240
|
action="download",
|
|
222
241
|
resource="clob content",
|
|
223
242
|
)
|
|
224
|
-
|
|
243
|
+
raise
|
|
225
244
|
except Exception as e:
|
|
226
|
-
|
|
245
|
+
if is_actionable_service_error(e):
|
|
246
|
+
log_scoped_read_hint(
|
|
247
|
+
credential_kind=credential_kind,
|
|
248
|
+
anonymous=anonymous,
|
|
249
|
+
office=office,
|
|
250
|
+
action="download",
|
|
251
|
+
resource="clob content",
|
|
252
|
+
)
|
|
253
|
+
raise
|
|
254
|
+
logging.error(format_local_download_error(e, CLOB_DOCS_URL))
|
|
227
255
|
sys.exit(1)
|
|
228
256
|
|
|
229
257
|
|
|
@@ -305,8 +333,8 @@ def list_cmd(
|
|
|
305
333
|
api_key: str,
|
|
306
334
|
anonymous: bool = False,
|
|
307
335
|
):
|
|
308
|
-
|
|
309
|
-
cwms
|
|
336
|
+
credential_kind = _resolve_credential_kind(api_key, anonymous)
|
|
337
|
+
init_cwms_session(cwms, api_root=api_root, api_key=api_key, anonymous=anonymous)
|
|
310
338
|
try:
|
|
311
339
|
df = list_clobs(
|
|
312
340
|
office=office,
|
|
@@ -319,7 +347,7 @@ def list_cmd(
|
|
|
319
347
|
)
|
|
320
348
|
except Exception:
|
|
321
349
|
log_scoped_read_hint(
|
|
322
|
-
|
|
350
|
+
credential_kind=credential_kind,
|
|
323
351
|
anonymous=anonymous,
|
|
324
352
|
office=office,
|
|
325
353
|
action="list",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os
|
|
3
3
|
import subprocess
|
|
4
|
-
import sys
|
|
5
4
|
import textwrap
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
from typing import Optional
|
|
@@ -24,9 +23,12 @@ from cwmscli.utils import (
|
|
|
24
23
|
)
|
|
25
24
|
from cwmscli.utils.auth import DEFAULT_REDIRECT_HOST, DEFAULT_REDIRECT_PORT
|
|
26
25
|
from cwmscli.utils.deps import requires
|
|
26
|
+
from cwmscli.utils.interaction import is_non_interactive
|
|
27
27
|
from cwmscli.utils.update import (
|
|
28
28
|
build_update_package_spec,
|
|
29
|
+
get_update_environment,
|
|
29
30
|
launch_windows_update,
|
|
31
|
+
looks_like_externally_managed_environment,
|
|
30
32
|
looks_like_missing_version,
|
|
31
33
|
)
|
|
32
34
|
from cwmscli.utils.version import get_cwms_cli_version
|
|
@@ -416,6 +418,7 @@ def csv2cwms_cmd(**kwargs):
|
|
|
416
418
|
def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
|
|
417
419
|
current_version = get_cwms_cli_version()
|
|
418
420
|
package_spec = build_update_package_spec(target_version)
|
|
421
|
+
update_environment = get_update_environment()
|
|
419
422
|
|
|
420
423
|
click.echo(
|
|
421
424
|
"Current cwms-cli version: " f"{colors.c(current_version, 'cyan', bright=True)}"
|
|
@@ -428,12 +431,41 @@ def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
|
|
|
428
431
|
else:
|
|
429
432
|
click.echo("Requested cwms-cli version: latest available release")
|
|
430
433
|
|
|
431
|
-
|
|
434
|
+
click.echo("Update environment:")
|
|
435
|
+
click.echo(f" Python executable: {update_environment.python_executable}")
|
|
436
|
+
click.echo(
|
|
437
|
+
f" Environment: {update_environment.environment_prefix} "
|
|
438
|
+
f"({update_environment.environment_type})"
|
|
439
|
+
)
|
|
440
|
+
click.echo(f" Package metadata location: {update_environment.package_location}")
|
|
441
|
+
if update_environment.editable_project_location:
|
|
442
|
+
click.echo(
|
|
443
|
+
" Editable project location: "
|
|
444
|
+
f"{update_environment.editable_project_location}"
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
cmd = [
|
|
448
|
+
update_environment.python_executable,
|
|
449
|
+
"-m",
|
|
450
|
+
"pip",
|
|
451
|
+
"install",
|
|
452
|
+
"--upgrade",
|
|
453
|
+
package_spec,
|
|
454
|
+
]
|
|
432
455
|
if pre:
|
|
433
456
|
cmd.append("--pre")
|
|
434
457
|
|
|
435
458
|
if not yes:
|
|
436
|
-
|
|
459
|
+
if is_non_interactive():
|
|
460
|
+
raise click.ClickException(
|
|
461
|
+
"The update command requires confirmation, but prompting is "
|
|
462
|
+
"disabled in non-interactive mode. Re-run with --yes to authorize "
|
|
463
|
+
"the update."
|
|
464
|
+
)
|
|
465
|
+
proceed = click.confirm(
|
|
466
|
+
"Proceed with updating cwms-cli in this environment via pip?",
|
|
467
|
+
default=True,
|
|
468
|
+
)
|
|
437
469
|
if not proceed:
|
|
438
470
|
click.echo(colors.warn("Update canceled."))
|
|
439
471
|
return
|
|
@@ -472,6 +504,16 @@ def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
|
|
|
472
504
|
|
|
473
505
|
if result.returncode != 0:
|
|
474
506
|
pip_output = "\n".join(part for part in [result.stdout, result.stderr] if part)
|
|
507
|
+
if looks_like_externally_managed_environment(pip_output):
|
|
508
|
+
raise click.ClickException(
|
|
509
|
+
colors.err(
|
|
510
|
+
"The selected Python installation is externally managed, so pip "
|
|
511
|
+
"refused to update cwms-cli. Install cwms-cli in a virtual "
|
|
512
|
+
"environment or with pipx, then run that installation's "
|
|
513
|
+
"cwms-cli update command. cwms-cli will not use "
|
|
514
|
+
"--break-system-packages automatically."
|
|
515
|
+
)
|
|
516
|
+
)
|
|
475
517
|
if target_version and looks_like_missing_version(pip_output, package_spec):
|
|
476
518
|
raise click.ClickException(
|
|
477
519
|
colors.err(
|
|
@@ -492,13 +534,15 @@ def update_cli_cmd(target_version: Optional[str], pre: bool, yes: bool) -> None:
|
|
|
492
534
|
@click.group(
|
|
493
535
|
"blob",
|
|
494
536
|
help="Manage CWMS Blobs (upload, download, delete, update, list)",
|
|
495
|
-
epilog=textwrap.dedent(
|
|
537
|
+
epilog=textwrap.dedent(
|
|
538
|
+
"""
|
|
496
539
|
Example Usage:\n
|
|
497
540
|
- Store a PDF/image as a CWMS blob with optional description\n
|
|
498
541
|
- Download a blob by id to your local filesystem\n
|
|
499
542
|
- Update a blob's name/description/mime-type\n
|
|
500
543
|
- Bulk list blobs for an office
|
|
501
|
-
"""
|
|
544
|
+
"""
|
|
545
|
+
),
|
|
502
546
|
)
|
|
503
547
|
def blob_group():
|
|
504
548
|
pass
|
|
@@ -671,10 +715,15 @@ def update_cmd(**kwargs):
|
|
|
671
715
|
show_default=True,
|
|
672
716
|
help="Sort descending instead of ascending.",
|
|
673
717
|
)
|
|
674
|
-
@click.option(
|
|
718
|
+
@click.option(
|
|
719
|
+
"--limit",
|
|
720
|
+
type=click.IntRange(min=1),
|
|
721
|
+
default=None,
|
|
722
|
+
help="Max rows to show.",
|
|
723
|
+
)
|
|
675
724
|
@click.option(
|
|
676
725
|
"--page-size",
|
|
677
|
-
type=
|
|
726
|
+
type=click.IntRange(min=1),
|
|
678
727
|
default=None,
|
|
679
728
|
help="Max rows to request from the blob endpoint. Defaults to --limit if set, otherwise no pagination (all results in one page).",
|
|
680
729
|
)
|
|
@@ -706,12 +755,14 @@ def list_cmd(**kwargs):
|
|
|
706
755
|
@click.group(
|
|
707
756
|
"clob",
|
|
708
757
|
help="Manage CWMS Clobs (upload, download, delete, update, list)",
|
|
709
|
-
epilog=textwrap.dedent(
|
|
758
|
+
epilog=textwrap.dedent(
|
|
759
|
+
"""
|
|
710
760
|
Example Usage:\n
|
|
711
761
|
- Download a clob by id to your local filesystem\n
|
|
712
762
|
- Update a clob's name/description/mime-type\n
|
|
713
763
|
- Bulk list clobs for an office
|
|
714
|
-
"""
|
|
764
|
+
"""
|
|
765
|
+
),
|
|
715
766
|
)
|
|
716
767
|
@requires(reqs.cwms)
|
|
717
768
|
def clob_group():
|
|
@@ -837,10 +888,15 @@ def update_cmd(**kwargs):
|
|
|
837
888
|
show_default=True,
|
|
838
889
|
help="Sort descending instead of ascending.",
|
|
839
890
|
)
|
|
840
|
-
@click.option(
|
|
891
|
+
@click.option(
|
|
892
|
+
"--limit",
|
|
893
|
+
type=click.IntRange(min=1),
|
|
894
|
+
default=None,
|
|
895
|
+
help="Max rows to show.",
|
|
896
|
+
)
|
|
841
897
|
@click.option(
|
|
842
898
|
"--page-size",
|
|
843
|
-
type=
|
|
899
|
+
type=click.IntRange(min=1),
|
|
844
900
|
default=None,
|
|
845
901
|
help="Max rows to request from the clob endpoint. Defaults to --limit when set.",
|
|
846
902
|
)
|