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.
Files changed (70) hide show
  1. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/PKG-INFO +14 -3
  2. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/README.md +13 -2
  3. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/__main__.py +1 -0
  4. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/commands_cwms.py +45 -7
  5. cwms_cli-0.3.3/cwmscli/commands/csv2cwms/README.md +21 -0
  6. cwms_cli-0.3.3/cwmscli/commands/csv2cwms/__main__.py +130 -0
  7. cwms_cli-0.3.3/cwmscli/commands/csv2cwms/config.py +119 -0
  8. cwms_cli-0.3.3/cwmscli/commands/csv2cwms/doclinks.py +16 -0
  9. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/examples/complete_config.json +7 -1
  10. cwms_cli-0.3.3/cwmscli/commands/csv2cwms/parser.py +74 -0
  11. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/data/sample_config.json +2 -1
  12. cwms_cli-0.3.3/cwmscli/commands/csv2cwms/tests/test_dateutils.py +147 -0
  13. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/test_expressions.py +6 -1
  14. cwms_cli-0.3.3/cwmscli/commands/csv2cwms/tests/test_fileio.py +75 -0
  15. cwms_cli-0.3.3/cwmscli/commands/csv2cwms/tests/test_main.py +492 -0
  16. cwms_cli-0.3.3/cwmscli/commands/csv2cwms/transform.py +257 -0
  17. cwms_cli-0.3.3/cwmscli/commands/csv2cwms/utils/__init__.py +11 -0
  18. cwms_cli-0.3.3/cwmscli/commands/csv2cwms/utils/dateutils.py +224 -0
  19. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/utils/expression.py +21 -5
  20. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/utils/fileio.py +3 -2
  21. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/utils/logging.py +3 -3
  22. cwms_cli-0.3.3/cwmscli/commands/csv2cwms/writer.py +31 -0
  23. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/click_help.py +47 -5
  24. cwms_cli-0.3.3/cwmscli/utils/intervals.py +71 -0
  25. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/pyproject.toml +1 -1
  26. cwms_cli-0.3.1/cwmscli/commands/csv2cwms/README.md +0 -51
  27. cwms_cli-0.3.1/cwmscli/commands/csv2cwms/__main__.py +0 -265
  28. cwms_cli-0.3.1/cwmscli/commands/csv2cwms/examples/hourly.json +0 -243
  29. cwms_cli-0.3.1/cwmscli/commands/csv2cwms/examples/minutes.json +0 -315
  30. cwms_cli-0.3.1/cwmscli/commands/csv2cwms/tests/test_dateutils.py +0 -68
  31. cwms_cli-0.3.1/cwmscli/commands/csv2cwms/tests/test_fileio.py +0 -43
  32. cwms_cli-0.3.1/cwmscli/commands/csv2cwms/utils/__init__.py +0 -5
  33. cwms_cli-0.3.1/cwmscli/commands/csv2cwms/utils/dateutils.py +0 -105
  34. cwms_cli-0.3.1/cwmscli/commands/csv2cwms/utils/terminal.py +0 -45
  35. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/LICENSE +0 -0
  36. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/__init__.py +0 -0
  37. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/callbacks/__init__.py +0 -0
  38. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/blob.py +0 -0
  39. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/.gitignore +0 -0
  40. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/__init__.py +0 -0
  41. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/__init__.py +0 -0
  42. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/data/.gitignore +0 -0
  43. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/data/expected_brok_output.json +0 -0
  44. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/data/sample_brok.csv +0 -0
  45. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/csv2cwms/tests/skip_test_integration_pipeline.py +0 -0
  46. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/commands/shef_critfile_import.py +0 -0
  47. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/README.md +0 -0
  48. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/__init__.py +0 -0
  49. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/__main__.py +0 -0
  50. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/location/location.py +0 -0
  51. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/location/location_ids.py +0 -0
  52. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/location/location_ids_bygroup.py +0 -0
  53. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/root.py +0 -0
  54. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/timeseries/timeseries.py +0 -0
  55. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/load/timeseries/timeseries_ids.py +0 -0
  56. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/requirements.py +0 -0
  57. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/usgs/__init__.py +0 -0
  58. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/usgs/__main__.py +0 -0
  59. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/usgs/getUSGS_ratings_cda.py +0 -0
  60. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/usgs/getusgs_cda.py +0 -0
  61. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/usgs/getusgs_measurements_cda.py +0 -0
  62. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/usgs/rating_ini_file_import.py +0 -0
  63. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/__init__.py +0 -0
  64. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/colors.py +0 -0
  65. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/deps.py +0 -0
  66. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/io.py +0 -0
  67. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/logging/__init__.py +0 -0
  68. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/logging/formatters.py +0 -0
  69. {cwms_cli-0.3.1 → cwms_cli-0.3.3}/cwmscli/utils/ssl_errors.py +0 -0
  70. {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.1
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
- pip3 install git+https://github.com/HydrologicEngineeringCenter/cwms-cli.git@main
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
- pip3 install git+https://github.com/HydrologicEngineeringCenter/cwms-cli.git@main
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}
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "interval": null,
3
+ "use_if_multiple": "last",
3
4
  "input_files": {
4
5
  "BROK": {
5
6
  "data_path": "cwmscli/commands/csv2cwms/tests/data/sample_brok.csv",
@@ -42,4 +43,4 @@
42
43
  }
43
44
  }
44
45
  }
45
- }
46
+ }
@@ -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)