cwms-cli 0.3.1__tar.gz → 0.3.3__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.1 → cwms_cli-0.3.3}/PKG-INFO +14 -3
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/README.md +13 -2
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/__main__.py +1 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/commands_cwms.py +45 -7
- cwms_cli-0.3.3/cwmscli/commands/csv2cwms/README.md +21 -0
- cwms_cli-0.3.3/cwmscli/commands/csv2cwms/__main__.py +130 -0
- cwms_cli-0.3.3/cwmscli/commands/csv2cwms/config.py +119 -0
- cwms_cli-0.3.3/cwmscli/commands/csv2cwms/doclinks.py +16 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/examples/complete_config.json +7 -1
- cwms_cli-0.3.3/cwmscli/commands/csv2cwms/parser.py +74 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/data/sample_config.json +2 -1
- cwms_cli-0.3.3/cwmscli/commands/csv2cwms/tests/test_dateutils.py +147 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/test_expressions.py +6 -1
- cwms_cli-0.3.3/cwmscli/commands/csv2cwms/tests/test_fileio.py +75 -0
- cwms_cli-0.3.3/cwmscli/commands/csv2cwms/tests/test_main.py +492 -0
- cwms_cli-0.3.3/cwmscli/commands/csv2cwms/transform.py +257 -0
- cwms_cli-0.3.3/cwmscli/commands/csv2cwms/utils/__init__.py +11 -0
- cwms_cli-0.3.3/cwmscli/commands/csv2cwms/utils/dateutils.py +224 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/utils/expression.py +21 -5
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/utils/fileio.py +3 -2
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/utils/logging.py +3 -3
- cwms_cli-0.3.3/cwmscli/commands/csv2cwms/writer.py +31 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/click_help.py +47 -5
- cwms_cli-0.3.3/cwmscli/utils/intervals.py +71 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/pyproject.toml +1 -1
- cwms_cli-0.3.1/cwmscli/commands/csv2cwms/README.md +0 -51
- cwms_cli-0.3.1/cwmscli/commands/csv2cwms/__main__.py +0 -265
- cwms_cli-0.3.1/cwmscli/commands/csv2cwms/examples/hourly.json +0 -243
- cwms_cli-0.3.1/cwmscli/commands/csv2cwms/examples/minutes.json +0 -315
- cwms_cli-0.3.1/cwmscli/commands/csv2cwms/tests/test_dateutils.py +0 -68
- cwms_cli-0.3.1/cwmscli/commands/csv2cwms/tests/test_fileio.py +0 -43
- cwms_cli-0.3.1/cwmscli/commands/csv2cwms/utils/__init__.py +0 -5
- cwms_cli-0.3.1/cwmscli/commands/csv2cwms/utils/dateutils.py +0 -105
- cwms_cli-0.3.1/cwmscli/commands/csv2cwms/utils/terminal.py +0 -45
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/LICENSE +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/__init__.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/callbacks/__init__.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/blob.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/.gitignore +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/__init__.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/data/.gitignore +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/shef_critfile_import.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/README.md +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/__init__.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/__main__.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/location/location.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/location/location_ids.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/location/location_ids_bygroup.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/root.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/timeseries/timeseries.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/timeseries/timeseries_ids.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/requirements.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/usgs/__init__.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/usgs/__main__.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/usgs/getUSGS_ratings_cda.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/usgs/getusgs_cda.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/usgs/getusgs_measurements_cda.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/usgs/rating_ini_file_import.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/__init__.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/colors.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/deps.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/io.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/logging/__init__.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/logging/formatters.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/ssl_errors.py +0 -0
- {cwms_cli-0.3.1 → cwms_cli-0.3.3}/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.3
|
|
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
|
|
@@ -30,9 +30,20 @@ A collection of scripts to create, read, update, list, and delete data through C
|
|
|
30
30
|
## Install
|
|
31
31
|
|
|
32
32
|
```sh
|
|
33
|
-
|
|
33
|
+
pip install cwms-cli
|
|
34
|
+
```
|
|
35
|
+
Note: You may need to run `python -m pip install cwms-cli` if PIP is not in your path.
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
### Update
|
|
39
|
+
```sh
|
|
40
|
+
pip install cwms-cli --upgrade
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or as of version `0.3.0+`
|
|
44
|
+
```sh
|
|
45
|
+
cwms-cli update
|
|
34
46
|
```
|
|
35
|
-
Note: If you are on Windows OS, you may just need to use the command `pip`
|
|
36
47
|
|
|
37
48
|
## Command line implementation
|
|
38
49
|
|
|
@@ -7,9 +7,20 @@ A collection of scripts to create, read, update, list, and delete data through C
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
|
|
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
|
+
pip install cwms-cli --upgrade
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or as of version `0.3.0+`
|
|
21
|
+
```sh
|
|
22
|
+
cwms-cli update
|
|
11
23
|
```
|
|
12
|
-
Note: If you are on Windows OS, you may just need to use the command `pip`
|
|
13
24
|
|
|
14
25
|
## Command line implementation
|
|
15
26
|
|
|
@@ -52,6 +52,7 @@ def cli(log_file: Optional[str], no_color: bool, log_level: str) -> None:
|
|
|
52
52
|
cli.add_command(usgs_group, name="usgs")
|
|
53
53
|
cli.add_command(commands_cwms.shefcritimport)
|
|
54
54
|
cli.add_command(commands_cwms.csv2cwms_cmd)
|
|
55
|
+
cli.add_command(commands_cwms.update_cli_cmd)
|
|
55
56
|
cli.add_command(commands_cwms.blob_group)
|
|
56
57
|
cli.add_command(load.load_group)
|
|
57
58
|
add_version_to_help_tree(cli)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
1
3
|
import textwrap
|
|
2
4
|
|
|
3
5
|
import click
|
|
@@ -7,6 +9,7 @@ from cwmscli.callbacks import csv_to_list
|
|
|
7
9
|
from cwmscli.commands import csv2cwms
|
|
8
10
|
from cwmscli.utils import api_key_loc_option, common_api_options, to_uppercase
|
|
9
11
|
from cwmscli.utils.deps import requires
|
|
12
|
+
from cwmscli.utils.version import get_cwms_cli_version
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
@click.command(
|
|
@@ -62,13 +65,6 @@ def shefcritimport(filename, office, api_root, api_key, api_key_loc):
|
|
|
62
65
|
type=click.Path(exists=True),
|
|
63
66
|
help="Path to JSON config file",
|
|
64
67
|
)
|
|
65
|
-
@click.option(
|
|
66
|
-
"-df",
|
|
67
|
-
"--data-file",
|
|
68
|
-
"data_file",
|
|
69
|
-
type=str,
|
|
70
|
-
help="Override CSV file (else use config)",
|
|
71
|
-
)
|
|
72
68
|
@click.option("--log", show_default=True, help="Path to the log file.")
|
|
73
69
|
@click.option("--dry-run", is_flag=True, help="Log only (no HTTP calls)")
|
|
74
70
|
@click.option("--begin", type=str, help="YYYY-MM-DDTHH:MM (local to --tz)")
|
|
@@ -90,6 +86,48 @@ def csv2cwms_cmd(**kwargs):
|
|
|
90
86
|
csv2_main(**kwargs)
|
|
91
87
|
|
|
92
88
|
|
|
89
|
+
@click.command("update", help="Update cwms-cli to the latest version using pip.")
|
|
90
|
+
@click.option(
|
|
91
|
+
"--pre",
|
|
92
|
+
is_flag=True,
|
|
93
|
+
default=False,
|
|
94
|
+
help="Include pre-release versions during update.",
|
|
95
|
+
)
|
|
96
|
+
@click.option(
|
|
97
|
+
"-y",
|
|
98
|
+
"--yes",
|
|
99
|
+
is_flag=True,
|
|
100
|
+
default=False,
|
|
101
|
+
help="Skip confirmation prompt and run update immediately.",
|
|
102
|
+
)
|
|
103
|
+
def update_cli_cmd(pre: bool, yes: bool) -> None:
|
|
104
|
+
current_version = get_cwms_cli_version()
|
|
105
|
+
click.echo(f"Current cwms-cli version: {current_version}")
|
|
106
|
+
|
|
107
|
+
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "cwms-cli"]
|
|
108
|
+
if pre:
|
|
109
|
+
cmd.append("--pre")
|
|
110
|
+
|
|
111
|
+
if not yes:
|
|
112
|
+
proceed = click.confirm("Proceed with updating cwms-cli via pip?", default=True)
|
|
113
|
+
if not proceed:
|
|
114
|
+
click.echo("Update canceled.")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
click.echo(f"Running: {' '.join(cmd)}")
|
|
118
|
+
try:
|
|
119
|
+
result = subprocess.run(cmd, check=False)
|
|
120
|
+
except OSError as e:
|
|
121
|
+
raise click.ClickException(f"Unable to run pip update command: {e}") from e
|
|
122
|
+
|
|
123
|
+
if result.returncode != 0:
|
|
124
|
+
raise click.ClickException(
|
|
125
|
+
"cwms-cli update failed. Please review pip output above."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
click.echo("Update complete. Run `cwms-cli --version` to verify.")
|
|
129
|
+
|
|
130
|
+
|
|
93
131
|
# region Blob
|
|
94
132
|
# ================================================================================
|
|
95
133
|
# BLOB
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# CSV2CWMS
|
|
2
|
+
|
|
3
|
+
Writes CSV timeseries data to CDA using a configuration file.
|
|
4
|
+
|
|
5
|
+
For end-user documentation, use the Read the Docs pages for `csv2cwms`. Those
|
|
6
|
+
pages are the primary source for usage, setup, configuration, and examples.
|
|
7
|
+
|
|
8
|
+
Recommended starting points:
|
|
9
|
+
|
|
10
|
+
- Command: https://cwms-cli.readthedocs.io/en/latest/cli/csv2cwms.html
|
|
11
|
+
- Setup: https://cwms-cli.readthedocs.io/en/latest/cli/setup.html
|
|
12
|
+
- Complete config Example: https://cwms-cli.readthedocs.io/en/latest/cli/csv2cwms_complete_config.html
|
|
13
|
+
|
|
14
|
+
Canonical example files used by those docs:
|
|
15
|
+
|
|
16
|
+
- `examples/complete_config.json`
|
|
17
|
+
- `tests/data/sample_config.json`
|
|
18
|
+
- `tests/data/sample_brok.csv`
|
|
19
|
+
|
|
20
|
+
For contributors maintaining the docs, see the repository contribution guide in
|
|
21
|
+
`CONTRIBUTING.md` and the source docs under `docs/cli/`.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Script Entry File
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
import cwms
|
|
8
|
+
|
|
9
|
+
# Add the current directory to the path
|
|
10
|
+
# This is necessary for the script to be run as a standalone script
|
|
11
|
+
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from cwmscli.utils.colors import c
|
|
15
|
+
|
|
16
|
+
from . import __author__, __license__, __version__
|
|
17
|
+
from .config import config_check as _config_check
|
|
18
|
+
from .config import resolve_input_files
|
|
19
|
+
from .parser import parse_file as _parse_file
|
|
20
|
+
from .transform import load_timeseries as _load_timeseries
|
|
21
|
+
from .utils import logger, read_config, safe_zoneinfo, setup_logger
|
|
22
|
+
from .writer import write_timeseries
|
|
23
|
+
except ImportError:
|
|
24
|
+
from parser import parse_file as _parse_file
|
|
25
|
+
|
|
26
|
+
from __init__ import __author__, __license__, __version__
|
|
27
|
+
from config import config_check as _config_check
|
|
28
|
+
from config import resolve_input_files
|
|
29
|
+
from transform import load_timeseries as _load_timeseries
|
|
30
|
+
from utils import logger, read_config, safe_zoneinfo, setup_logger
|
|
31
|
+
from writer import write_timeseries
|
|
32
|
+
|
|
33
|
+
from cwmscli.utils.colors import c
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_file(file_path, begin_time, date_format, timezone="GMT", file_config=None):
|
|
37
|
+
return _parse_file(
|
|
38
|
+
file_path,
|
|
39
|
+
begin_time,
|
|
40
|
+
date_format,
|
|
41
|
+
timezone=timezone,
|
|
42
|
+
file_config=file_config,
|
|
43
|
+
logger=logger,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_timeseries(file_data, file_key, config):
|
|
48
|
+
return _load_timeseries(file_data, file_key, config, logger)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def config_check(config):
|
|
52
|
+
return _config_check(config, logger)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve_begin_time(tz, begin):
|
|
56
|
+
if begin:
|
|
57
|
+
try:
|
|
58
|
+
return datetime.strptime(begin, "%Y-%m-%dT%H:%M").replace(tzinfo=tz)
|
|
59
|
+
except ValueError as err:
|
|
60
|
+
raise ValueError("--begin must be in format YYYY-MM-DDTHH:MM") from err
|
|
61
|
+
return datetime.now(tz)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(*args, **kwargs):
|
|
65
|
+
"""
|
|
66
|
+
Main function to execute the scada_ts script.
|
|
67
|
+
This function serves as the entry point for the script.
|
|
68
|
+
"""
|
|
69
|
+
start_time = time.time()
|
|
70
|
+
tz = safe_zoneinfo(kwargs.get("tz"))
|
|
71
|
+
begin_time = _resolve_begin_time(tz, kwargs.get("begin"))
|
|
72
|
+
|
|
73
|
+
cwms.api.init_session(
|
|
74
|
+
api_root=kwargs.get("api_root"), api_key=kwargs.get("api_key")
|
|
75
|
+
)
|
|
76
|
+
setup_logger(kwargs.get("log"), verbose=kwargs.get("verbose"))
|
|
77
|
+
logger.info(f"Begin time: {begin_time}")
|
|
78
|
+
logger.info(f"Timezone: {c(str(tz), 'cyan')}")
|
|
79
|
+
|
|
80
|
+
if kwargs.get("coop"):
|
|
81
|
+
host = os.getenv("CDA_COOP_HOST")
|
|
82
|
+
if not host:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"Environment variable CDA_COOP_HOST must be set to use --coop flag."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
config_path = kwargs.get("config_path")
|
|
88
|
+
config = read_config(config_path)
|
|
89
|
+
config_check(config)
|
|
90
|
+
input_files = resolve_input_files(config, kwargs.get("input_keys"))
|
|
91
|
+
logger.info(f"Started for {','.join(input_files)} input files.")
|
|
92
|
+
|
|
93
|
+
for file_name in input_files:
|
|
94
|
+
config_item = config.get("input_files", {}).get(file_name, {})
|
|
95
|
+
data_file = config_item.get("data_path", "")
|
|
96
|
+
if not data_file:
|
|
97
|
+
logger.warning(
|
|
98
|
+
f"No data file specified for input-keys '{file_name}' in {config_path}. {c(f'Skipping {file_name}', 'red')}. Please provide a valid CSV file path by ensuring the 'data_path' key is set in the config."
|
|
99
|
+
)
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
csv_data = parse_file(
|
|
103
|
+
data_file,
|
|
104
|
+
begin_time,
|
|
105
|
+
config_item.get("date_format"),
|
|
106
|
+
kwargs.get("tz"),
|
|
107
|
+
config_item,
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
ts_data = load_timeseries(csv_data, file_name, config)
|
|
111
|
+
except ValueError as err:
|
|
112
|
+
logger.error(f"Error loading timeseries for {file_name}: {err}")
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
write_timeseries(
|
|
116
|
+
file_name=file_name,
|
|
117
|
+
ts_data=ts_data,
|
|
118
|
+
config_item=config_item,
|
|
119
|
+
office=kwargs.get("office"),
|
|
120
|
+
dry_run=kwargs.get("dry_run"),
|
|
121
|
+
config_path=config_path,
|
|
122
|
+
logger=logger,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
logger.debug(f"\tExecution time: {round(time.time() - start_time, 3)} seconds.")
|
|
126
|
+
logger.debug(f"\tMemory usage: {round(os.sys.getsizeof(locals()) / 1024, 2)} KB")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from cwmscli.utils.colors import c
|
|
2
|
+
|
|
3
|
+
from .doclinks import COMPLETE_CONFIG_DOC_URL, with_doc_links
|
|
4
|
+
|
|
5
|
+
VALID_USE_IF_MULTIPLE = {"first", "last", "average", "error"}
|
|
6
|
+
TOP_LEVEL_CONFIG_KEYS = {
|
|
7
|
+
"interval",
|
|
8
|
+
"round_to_nearest",
|
|
9
|
+
"use_if_multiple",
|
|
10
|
+
"input_files",
|
|
11
|
+
"projects",
|
|
12
|
+
}
|
|
13
|
+
FILE_CONFIG_KEYS = {
|
|
14
|
+
"data_path",
|
|
15
|
+
"store_rule",
|
|
16
|
+
"date_col",
|
|
17
|
+
"date_format",
|
|
18
|
+
"round_to_nearest",
|
|
19
|
+
"use_if_multiple",
|
|
20
|
+
"timeseries",
|
|
21
|
+
}
|
|
22
|
+
TIMESERIES_CONFIG_KEYS = {"columns", "units", "precision"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _raise_invalid_keys(level_name, owner_name, invalid_keys):
|
|
26
|
+
invalid = ", ".join(sorted(invalid_keys))
|
|
27
|
+
owner = f" for {owner_name}" if owner_name else ""
|
|
28
|
+
raise ValueError(
|
|
29
|
+
with_doc_links(
|
|
30
|
+
f"Invalid configuration key(s) {c(invalid, 'yellow')} found in {level_name}{owner}.",
|
|
31
|
+
COMPLETE_CONFIG_DOC_URL,
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validate_allowed_keys(config, logger):
|
|
37
|
+
invalid_top_level = set(config) - TOP_LEVEL_CONFIG_KEYS
|
|
38
|
+
if invalid_top_level:
|
|
39
|
+
_raise_invalid_keys("top-level config", None, invalid_top_level)
|
|
40
|
+
|
|
41
|
+
input_files = config.get("input_files", {})
|
|
42
|
+
for file_key, file_data in input_files.items():
|
|
43
|
+
invalid_file_keys = set(file_data) - FILE_CONFIG_KEYS
|
|
44
|
+
if invalid_file_keys:
|
|
45
|
+
_raise_invalid_keys("input file config", file_key, invalid_file_keys)
|
|
46
|
+
|
|
47
|
+
timeseries = file_data.get("timeseries", {})
|
|
48
|
+
for ts_name, ts_data in timeseries.items():
|
|
49
|
+
invalid_ts_keys = set(ts_data) - TIMESERIES_CONFIG_KEYS
|
|
50
|
+
if invalid_ts_keys:
|
|
51
|
+
_raise_invalid_keys("timeseries config", ts_name, invalid_ts_keys)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_use_if_multiple(config, file_config):
|
|
55
|
+
# File-specific setting takes precedence over global setting, default to "error" if not set
|
|
56
|
+
strategy = file_config.get(
|
|
57
|
+
"use_if_multiple", config.get("use_if_multiple", "error")
|
|
58
|
+
)
|
|
59
|
+
normalized = str(strategy).strip().lower()
|
|
60
|
+
if normalized not in VALID_USE_IF_MULTIPLE:
|
|
61
|
+
valid = ", ".join(sorted(VALID_USE_IF_MULTIPLE))
|
|
62
|
+
raise ValueError(
|
|
63
|
+
with_doc_links(
|
|
64
|
+
f"Invalid use_if_multiple value {c(str(strategy), 'yellow')}. Expected one of {c(valid, 'cyan')}.",
|
|
65
|
+
COMPLETE_CONFIG_DOC_URL,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
return normalized
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def config_check(config, logger):
|
|
72
|
+
"""Checks a configuration file for required keys."""
|
|
73
|
+
resolve_use_if_multiple(config, {})
|
|
74
|
+
if not config.get("interval"):
|
|
75
|
+
logger.warning(
|
|
76
|
+
"Configuration file does not contain an 'interval' key (and value in seconds), this is recommended per CSV file to avoid ambiguity."
|
|
77
|
+
)
|
|
78
|
+
if config.get("projects"):
|
|
79
|
+
logger.warning(
|
|
80
|
+
"Configuration file contains a 'projects' key, this has been renamed to 'input_files' for clarity. Continuing for backwards compatibility."
|
|
81
|
+
)
|
|
82
|
+
config["input_files"] = config.pop("projects")
|
|
83
|
+
_validate_allowed_keys(config, logger)
|
|
84
|
+
if not config.get("input_files"):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
with_doc_links(
|
|
87
|
+
"Configuration file must contain an 'input_files' key.",
|
|
88
|
+
COMPLETE_CONFIG_DOC_URL,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
for file_key, file_data in config.get("input_files").items():
|
|
92
|
+
resolve_use_if_multiple(config, file_data)
|
|
93
|
+
# Only check the specified keys or if all keys are specified
|
|
94
|
+
if file_key != "all" and file_key != file_key.lower():
|
|
95
|
+
continue
|
|
96
|
+
if not file_data.get("timeseries"):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
with_doc_links(
|
|
99
|
+
f"Configuration file must contain a 'timeseries' key for file '{file_key}'.",
|
|
100
|
+
COMPLETE_CONFIG_DOC_URL,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
for ts_name, ts_data in file_data.get("timeseries").items():
|
|
104
|
+
if not ts_data.get("columns"):
|
|
105
|
+
raise ValueError(
|
|
106
|
+
with_doc_links(
|
|
107
|
+
f"Configuration file must contain a 'columns' key for timeseries '{ts_name}' in file '{file_key}'.",
|
|
108
|
+
COMPLETE_CONFIG_DOC_URL,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def resolve_input_files(config, input_keys):
|
|
114
|
+
input_files = config.get("input_files", {})
|
|
115
|
+
if input_keys:
|
|
116
|
+
if input_keys == "all":
|
|
117
|
+
return config.get("input_files", {}).keys()
|
|
118
|
+
return input_keys.split(",")
|
|
119
|
+
return input_files
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
DOCS_BASE_URL = "https://cwms-cli.readthedocs.io/en/latest/cli"
|
|
2
|
+
COMPLETE_CONFIG_DOC_URL = f"{DOCS_BASE_URL}/csv2cwms_complete_config.html"
|
|
3
|
+
SETUP_DOC_URL = f"{DOCS_BASE_URL}/setup.html"
|
|
4
|
+
API_ARGUMENTS_DOC_URL = f"{DOCS_BASE_URL}/api_arguments.html"
|
|
5
|
+
INTERVALS_DOC_URL = f"{DOCS_BASE_URL}/csv2cwms_intervals.html"
|
|
6
|
+
COMMAND_DOC_URL = f"{DOCS_BASE_URL}/csv2cwms.html"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def with_doc_links(message, *urls):
|
|
10
|
+
unique_urls = []
|
|
11
|
+
for url in urls:
|
|
12
|
+
if url and url not in unique_urls:
|
|
13
|
+
unique_urls.append(url)
|
|
14
|
+
if not unique_urls:
|
|
15
|
+
return message
|
|
16
|
+
return f"{message}\nSee documentation: {', '.join(unique_urls)}"
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"interval": 3600,
|
|
3
|
+
"round_to_nearest": true,
|
|
4
|
+
"use_if_multiple": "last",
|
|
3
5
|
"input_files": {
|
|
4
6
|
"BROK": {
|
|
5
7
|
"data_path": "cwmscli/commands/csv2cwms/tests/data/sample_brok.csv",
|
|
8
|
+
"store_rule": "REPLACE_ALL",
|
|
9
|
+
"date_col": "Time",
|
|
6
10
|
"date_format": [
|
|
7
11
|
"%m/%d/%Y %H:%M:%S",
|
|
8
12
|
"%m/%d/%Y %H:%M"
|
|
9
13
|
],
|
|
14
|
+
"round_to_nearest": true,
|
|
15
|
+
"use_if_multiple": "last",
|
|
10
16
|
"timeseries": {
|
|
11
17
|
"BROK.Elev.Inst.15Minutes.0.Rev-SCADA-cda": {
|
|
12
18
|
"columns": "Headwater",
|
|
@@ -16,4 +22,4 @@
|
|
|
16
22
|
}
|
|
17
23
|
}
|
|
18
24
|
}
|
|
19
|
-
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from cwmscli.utils.colors import c
|
|
2
|
+
|
|
3
|
+
from .doclinks import COMPLETE_CONFIG_DOC_URL, with_doc_links
|
|
4
|
+
from .utils import load_csv, parse_date, safe_zoneinfo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def resolve_date_column(header, file_config):
|
|
8
|
+
date_col = file_config.get("date_col")
|
|
9
|
+
if date_col is None:
|
|
10
|
+
return 0, header[0] if header else ""
|
|
11
|
+
|
|
12
|
+
normalized_header = {col.strip().lower(): i for i, col in enumerate(header)}
|
|
13
|
+
date_col_name = str(date_col).strip()
|
|
14
|
+
date_col_index = normalized_header.get(date_col_name.lower())
|
|
15
|
+
if date_col_index is None:
|
|
16
|
+
raise ValueError(
|
|
17
|
+
with_doc_links(
|
|
18
|
+
"Configured date_col "
|
|
19
|
+
f"{c(date_col_name, 'yellow')} was not found in the input file. "
|
|
20
|
+
"Available CSV columns: "
|
|
21
|
+
f"{c(', '.join(header), 'cyan')}",
|
|
22
|
+
COMPLETE_CONFIG_DOC_URL,
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
return date_col_index, header[date_col_index]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_file(
|
|
29
|
+
file_path, begin_time, date_format, timezone="GMT", file_config=None, logger=None
|
|
30
|
+
):
|
|
31
|
+
file_config = file_config or {}
|
|
32
|
+
csv_data = load_csv(file_path)
|
|
33
|
+
header = csv_data[0]
|
|
34
|
+
data = csv_data[1:]
|
|
35
|
+
ts_data = {}
|
|
36
|
+
source_timezone = safe_zoneinfo(timezone)
|
|
37
|
+
date_col_index, date_col_label = resolve_date_column(header, file_config)
|
|
38
|
+
if logger:
|
|
39
|
+
logger.debug(f"Begin time: {begin_time}")
|
|
40
|
+
for row in data:
|
|
41
|
+
# Skip empty rows or rows without a timestamp
|
|
42
|
+
if not row:
|
|
43
|
+
continue
|
|
44
|
+
if date_col_index >= len(row):
|
|
45
|
+
raise ValueError(
|
|
46
|
+
with_doc_links(
|
|
47
|
+
f"Configured date_col {c(date_col_label, 'yellow')} is missing from a CSV row in {c(file_path, 'red')}.",
|
|
48
|
+
COMPLETE_CONFIG_DOC_URL,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
row_datetime = parse_date(
|
|
53
|
+
row[date_col_index], tz_str=timezone, date_format=date_format
|
|
54
|
+
)
|
|
55
|
+
except ValueError as err:
|
|
56
|
+
if date_col_index == 0:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
with_doc_links(
|
|
59
|
+
"Unable to parse a timestamp from the first CSV column "
|
|
60
|
+
f"{c(date_col_label, 'yellow')} with value {c(str(row[0]), 'red')} in {c(file_path, 'red')}. "
|
|
61
|
+
"If the timestamp is in a different column, set "
|
|
62
|
+
f"{c('date_col', 'cyan')} in the input file config to that column name.",
|
|
63
|
+
COMPLETE_CONFIG_DOC_URL,
|
|
64
|
+
)
|
|
65
|
+
) from err
|
|
66
|
+
raise ValueError(
|
|
67
|
+
with_doc_links(
|
|
68
|
+
f"Unable to parse a timestamp from configured date_col {c(date_col_label, 'yellow')} "
|
|
69
|
+
f"with value {c(str(row[date_col_index]), 'red')} in {c(file_path, 'red')}.",
|
|
70
|
+
COMPLETE_CONFIG_DOC_URL,
|
|
71
|
+
)
|
|
72
|
+
) from err
|
|
73
|
+
ts_data.setdefault(int(row_datetime.timestamp()), []).append(row)
|
|
74
|
+
return {"header": header, "data": ts_data, "source_timezone": source_timezone}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from cwmscli.utils.intervals import ALL_INTERVAL_PARAMETERS
|
|
6
|
+
|
|
7
|
+
from ..utils.dateutils import (
|
|
8
|
+
determine_interval,
|
|
9
|
+
interval_parameter_to_seconds,
|
|
10
|
+
parse_date,
|
|
11
|
+
round_datetime_to_interval,
|
|
12
|
+
safe_zoneinfo,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_parse_date_valid_formats():
|
|
17
|
+
tz = safe_zoneinfo("UTC")
|
|
18
|
+
expected = datetime(2025, 3, 25, 14, 30, tzinfo=tz)
|
|
19
|
+
assert parse_date("03/25/2025 14:30:00") == expected
|
|
20
|
+
assert parse_date("03/25/2025 14:30") == expected
|
|
21
|
+
assert parse_date("03/25/2025 14") == datetime(2025, 3, 25, 14, tzinfo=tz)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_parse_date_invalid_format():
|
|
25
|
+
with pytest.raises(ValueError):
|
|
26
|
+
parse_date("25-03-2025")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_parse_date_uses_single_user_format():
|
|
30
|
+
tz = safe_zoneinfo("UTC")
|
|
31
|
+
expected = datetime(2025, 3, 25, 14, 30, tzinfo=tz)
|
|
32
|
+
assert (
|
|
33
|
+
parse_date("2025/03/25 14:30", date_format="%Y/%m/%d %H:%M", tz_str="UTC")
|
|
34
|
+
== expected
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_parse_date_uses_ordered_format_list():
|
|
39
|
+
tz = safe_zoneinfo("UTC")
|
|
40
|
+
expected = datetime(2025, 3, 25, 14, 30, tzinfo=tz)
|
|
41
|
+
assert (
|
|
42
|
+
parse_date(
|
|
43
|
+
"2025-03-25 14:30",
|
|
44
|
+
date_format=["%m/%d/%Y %H:%M", "%Y-%m-%d %H:%M"],
|
|
45
|
+
tz_str="UTC",
|
|
46
|
+
)
|
|
47
|
+
== expected
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_interval_parameter_to_seconds():
|
|
52
|
+
assert interval_parameter_to_seconds("15Minutes") == 900
|
|
53
|
+
assert interval_parameter_to_seconds("1Hour") == 3600
|
|
54
|
+
assert interval_parameter_to_seconds("1Day") == 86400
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_interval_parameter_list_includes_schema_values():
|
|
58
|
+
assert "1Week" in ALL_INTERVAL_PARAMETERS
|
|
59
|
+
assert "~15Minutes" in ALL_INTERVAL_PARAMETERS
|
|
60
|
+
assert "1Decade" in ALL_INTERVAL_PARAMETERS
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_interval_parameter_to_seconds_rejects_irregular_interval():
|
|
64
|
+
with pytest.raises(ValueError):
|
|
65
|
+
interval_parameter_to_seconds("~15Minutes")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_round_datetime_to_interval_hour():
|
|
69
|
+
tz = safe_zoneinfo("UTC")
|
|
70
|
+
dt = datetime(2025, 3, 25, 14, 31, tzinfo=tz)
|
|
71
|
+
assert round_datetime_to_interval(dt, "1Hour") == datetime(
|
|
72
|
+
2025, 3, 25, 15, 0, tzinfo=tz
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_round_datetime_to_interval_minutes():
|
|
77
|
+
tz = safe_zoneinfo("UTC")
|
|
78
|
+
dt = datetime(2025, 3, 25, 14, 8, tzinfo=tz)
|
|
79
|
+
assert round_datetime_to_interval(dt, "15Minutes") == datetime(
|
|
80
|
+
2025, 3, 25, 14, 15, tzinfo=tz
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_round_datetime_to_interval_5_minutes():
|
|
85
|
+
tz = safe_zoneinfo("UTC")
|
|
86
|
+
dt = datetime(2025, 3, 25, 14, 3, 31, tzinfo=tz)
|
|
87
|
+
assert round_datetime_to_interval(dt, "5Minutes") == datetime(
|
|
88
|
+
2025, 3, 25, 14, 5, tzinfo=tz
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_round_datetime_to_interval_30_minutes():
|
|
93
|
+
tz = safe_zoneinfo("UTC")
|
|
94
|
+
dt = datetime(2025, 3, 25, 14, 16, tzinfo=tz)
|
|
95
|
+
assert round_datetime_to_interval(dt, "30Minutes") == datetime(
|
|
96
|
+
2025, 3, 25, 14, 30, tzinfo=tz
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_determine_interval_regular_spacing():
|
|
101
|
+
now = datetime(2025, 3, 25, 14, 30, tzinfo=safe_zoneinfo("UTC"))
|
|
102
|
+
interval = 900 # 15 minutes
|
|
103
|
+
csv_data = {
|
|
104
|
+
int((now + timedelta(seconds=i * interval)).timestamp()): [] for i in range(5)
|
|
105
|
+
}
|
|
106
|
+
assert determine_interval(csv_data) == 900
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_determine_interval_mixed_spacing():
|
|
110
|
+
now = datetime(2025, 3, 25, 14, 30, tzinfo=safe_zoneinfo("UTC"))
|
|
111
|
+
timestamps = [
|
|
112
|
+
now,
|
|
113
|
+
now + timedelta(minutes=15),
|
|
114
|
+
now + timedelta(minutes=30),
|
|
115
|
+
now + timedelta(minutes=60), # outlier
|
|
116
|
+
now + timedelta(minutes=45),
|
|
117
|
+
]
|
|
118
|
+
csv_data = {int(dt.timestamp()): [] for dt in timestamps}
|
|
119
|
+
assert determine_interval(csv_data) == 900
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_determine_interval_duplicate_timestamps():
|
|
123
|
+
now = datetime(2025, 3, 25, 14, 30, tzinfo=safe_zoneinfo("UTC"))
|
|
124
|
+
timestamps = [
|
|
125
|
+
now,
|
|
126
|
+
now + timedelta(minutes=15),
|
|
127
|
+
now + timedelta(minutes=15), # duplicate
|
|
128
|
+
now + timedelta(minutes=30),
|
|
129
|
+
]
|
|
130
|
+
csv_data = {int(dt.timestamp()): [] for dt in timestamps}
|
|
131
|
+
assert determine_interval(csv_data) == 900
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_determine_interval_missing_values():
|
|
135
|
+
now = datetime(2025, 3, 25, 14, 30, tzinfo=safe_zoneinfo("UTC"))
|
|
136
|
+
csv_data = {
|
|
137
|
+
int((now + timedelta(minutes=15 * i)).timestamp()): []
|
|
138
|
+
for i in [0, 1, 3, 4] # skip index 2 (30-minute gap)
|
|
139
|
+
}
|
|
140
|
+
assert determine_interval(csv_data) == 900
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_determine_interval_insufficient_data():
|
|
144
|
+
now = datetime(2025, 3, 25, 14, 30, tzinfo=safe_zoneinfo("UTC"))
|
|
145
|
+
csv_data = {int(now.timestamp()): []} # only one row
|
|
146
|
+
with pytest.raises(ValueError):
|
|
147
|
+
determine_interval(csv_data)
|