diff --git a/cwmscli/load/location/location.py b/cwmscli/load/location/location.py index 3378a1a..f9e5922 100644 --- a/cwmscli/load/location/location.py +++ b/cwmscli/load/location/location.py @@ -5,6 +5,7 @@ from cwmscli import requirements as reqs from cwmscli.load.root import ( + csv_source_target_options, load_group, shared_source_target_options, validate_cda_targets, @@ -24,12 +25,14 @@ def location(ctx): @location.command( "ids-all", help=( - "Copy locations from a source CDA catalog to a target CDA. " + "Copy locations from a source CDA catalog to a target CDA, " + "or to/from a CSV file via --source-csv / --target-csv. " "The --like and --location-kind-like filters use CDA regex semantics. " f"Regex guide: {CDA_REGEXP_GUIDE_URL}" ), ) @shared_source_target_options +@csv_source_target_options(allow_source_csv=True, allow_target_csv=True) @click.option( "--like", default=None, @@ -57,14 +60,16 @@ def location(ctx): @requires(reqs.cwms) @validate_cda_targets def load_locations( - source_cda: str, - source_office: str, - target_cda: str, + source_cda: Optional[str], + source_office: Optional[str], + target_cda: Optional[str], target_api_key: Optional[str], verbose: int, dry_run: bool, like: Optional[str], location_kind_like: Optional[Iterable[str]] = None, + source_csv: Optional[str] = None, + target_csv: Optional[str] = None, ): from cwmscli.load.location.location_ids import load_locations as _load_locations @@ -77,14 +82,20 @@ def load_locations( dry_run=dry_run, like=like, location_kind_like=location_kind_like, + source_csv=source_csv, + target_csv=target_csv, ) @location.command( "ids-bygroup", - help="Copy locations from a CWMS Location Group (source CDA) to a target CDA.", + help=( + "Copy locations from a CWMS Location Group (source CDA) to a target CDA, " + "or export the resolved members to a CSV file via --target-csv." + ), ) @shared_source_target_options +@csv_source_target_options(allow_source_csv=False, allow_target_csv=True) @click.option( "--group-id", required=True, help="Location Group ID (e.g., 'Ark Basin')." ) @@ -112,7 +123,7 @@ def load_locations( def load_locations_from_group( source_cda: str, source_office: str, - target_cda: str, + target_cda: Optional[str], target_api_key: Optional[str], verbose: int, group_id: str, @@ -121,6 +132,7 @@ def load_locations_from_group( category_office_id: Optional[str], filter_office: bool, dry_run: bool, + target_csv: Optional[str] = None, ): from cwmscli.load.location.location_ids_bygroup import copy_from_group @@ -136,4 +148,5 @@ def load_locations_from_group( category_office_id=category_office_id, filter_office=filter_office, dry_run=dry_run, + target_csv=target_csv, ) diff --git a/cwmscli/load/location/location_ids.py b/cwmscli/load/location/location_ids.py index e567fe8..8aadb20 100644 --- a/cwmscli/load/location/location_ids.py +++ b/cwmscli/load/location/location_ids.py @@ -1,9 +1,11 @@ import logging +import math import re from typing import Iterable, Optional import click import cwms +import pandas as pd from cwmscli.utils import init_cwms_session from cwmscli.utils.links import CDA_REGEXP_GUIDE_URL @@ -12,18 +14,23 @@ def load_locations( - source_cda: str, - source_office: str, - target_cda: str, + source_cda: Optional[str], + source_office: Optional[str], + target_cda: Optional[str], target_api_key: Optional[str], verbose: int, dry_run: bool, like: Optional[str], location_kind_like: Optional[Iterable[str]] = "ALL", + source_csv: Optional[str] = None, + target_csv: Optional[str] = None, ): + src_label = source_csv or source_cda or "-" + tgt_label = target_csv or target_cda or "-" + if verbose: logger.info( - f"[load locations] source={source_cda} ({source_office}) -> target={target_cda}" + f"[load locations] source={src_label} ({source_office or '-'}) -> target={tgt_label}" ) logger.info( f" like={like or '-'} kinds={list(location_kind_like) or '-'} dry_run={dry_run}" @@ -35,63 +42,41 @@ def load_locations( ): logger.info(" CDA regex guide: %s", CDA_REGEXP_GUIDE_URL) - init_cwms_session(cwms, api_root=source_cda) - - cat_kwargs = {"office_id": source_office} - if like: - cat_kwargs["like"] = like - kinds = list(location_kind_like) if location_kind_like else ["ALL"] - if "ALL" in kinds: - kinds = ["ALL"] - - locations = [] - - if kinds == ["ALL"] and not like: - locations = cwms.get_locations(office_id=source_office).json + if source_csv: + df = pd.read_csv(source_csv) + locations = df.to_dict(orient="records") else: - seen_location_ids = set() - for kind in kinds: - cat_kwargs_k = dict(cat_kwargs) - if kind != "ALL": - cat_kwargs_k["location_kind_like"] = kind - - if verbose >= 2: - logger.debug(" > catalog query: %s", cat_kwargs_k) - - resp = cwms.get_locations_catalog(**cat_kwargs_k) - if resp.df.empty: - continue - - for location_id in resp.df["name"].tolist(): - if location_id in seen_location_ids: - continue - seen_location_ids.add(location_id) - if verbose >= 2: - logger.debug(" > location fetch: %s", location_id) - detail_resp = cwms.get_locations( - office_id=source_office, - location_ids=rf"^{re.escape(location_id)}$", - ) - if detail_resp and detail_resp.json: - locations.extend(detail_resp.json) + init_cwms_session(cwms, api_root=source_cda) + locations = _fetch_locations_from_cda( + source_office=source_office, + like=like, + location_kind_like=location_kind_like, + verbose=verbose, + ) if verbose: - logger.info("Fetched %s locations from source", len(locations)) + logger.info("Got %s locations from source", len(locations)) if dry_run: for loc in locations: logger.info( - f"[dry-run] would store Location(name={loc['name']}) to {target_cda} ({source_office})" + f"[dry-run] would store Location(name={loc['name']}) to {tgt_label} " + f"({source_office or loc.get('office-id') or '-'})" ) return - # init target once + if target_csv: + pd.DataFrame(locations).to_csv(target_csv, index=False) + click.echo(f"Wrote {len(locations)} locations to {target_csv}") + return + init_cwms_session(cwms, api_root=target_cda, api_key=target_api_key) errors = 0 for loc in locations: + loc = _clean_row(loc) try: - if loc["active"] is True: + if loc.get("active") is True: result = cwms.store_location(data=loc, fail_if_exists=False) if verbose: logger.info("%s", result) @@ -103,3 +88,60 @@ def load_locations( raise click.ClickException(f"Completed with {errors} error(s).") click.echo("Done.") + + +def _fetch_locations_from_cda( + source_office: str, + like: Optional[str], + location_kind_like: Optional[Iterable[str]], + verbose: int, +) -> list: + cat_kwargs = {"office_id": source_office} + if like: + cat_kwargs["like"] = like + kinds = list(location_kind_like) if location_kind_like else ["ALL"] + if "ALL" in kinds: + kinds = ["ALL"] + + if kinds == ["ALL"] and not like: + return cwms.get_locations(office_id=source_office).json + + locations = [] + seen_location_ids = set() + for kind in kinds: + cat_kwargs_k = dict(cat_kwargs) + if kind != "ALL": + cat_kwargs_k["location_kind_like"] = kind + + if verbose >= 2: + logger.debug(" > catalog query: %s", cat_kwargs_k) + + resp = cwms.get_locations_catalog(**cat_kwargs_k) + if resp.df.empty: + continue + + for location_id in resp.df["name"].tolist(): + if location_id in seen_location_ids: + continue + seen_location_ids.add(location_id) + if verbose >= 2: + logger.debug(" > location fetch: %s", location_id) + detail_resp = cwms.get_locations( + office_id=source_office, + location_ids=rf"^{re.escape(location_id)}$", + ) + if detail_resp and detail_resp.json: + locations.extend(detail_resp.json) + return locations + + +def _clean_row(loc: dict) -> dict: + cleaned = {} + for k, v in loc.items(): + if isinstance(v, float) and math.isnan(v): + cleaned[k] = None + elif isinstance(v, str) and v in ("True", "False"): + cleaned[k] = v == "True" + else: + cleaned[k] = v + return cleaned diff --git a/cwmscli/load/location/location_ids_bygroup.py b/cwmscli/load/location/location_ids_bygroup.py index 59caae1..e8800a2 100644 --- a/cwmscli/load/location/location_ids_bygroup.py +++ b/cwmscli/load/location/location_ids_bygroup.py @@ -5,6 +5,7 @@ import click import cwms +import pandas as pd from cwmscli.utils import init_cwms_session @@ -22,7 +23,7 @@ def exact_or_regex(ids: list[str]) -> str: def copy_from_group( source_cda: str, source_office: str, - target_cda: str, + target_cda: Optional[str], target_api_key: Optional[str], verbose: int, group_id: str, @@ -31,6 +32,7 @@ def copy_from_group( category_office_id: Optional[str], filter_office: bool, dry_run: bool, + target_csv: Optional[str] = None, ): group_office_id = group_office_id or source_office category_office_id = category_office_id or source_office @@ -103,10 +105,16 @@ def copy_from_group( if dry_run: for loc in locations: logger.info( - f"[dry-run] would store Location(name={loc['name']}) to {target_cda} ({source_office})" + f"[dry-run] would store Location(name={loc['name']}) to " + f"{target_csv or target_cda} ({source_office})" ) return + if target_csv: + pd.DataFrame(locations).to_csv(target_csv, index=False) + click.echo(f"Wrote {len(locations)} locations to {target_csv}") + return + try: init_cwms_session(cwms, api_root=target_cda, api_key=target_api_key) except Exception as e: diff --git a/cwmscli/load/root.py b/cwmscli/load/root.py index 61b753a..1366ad3 100644 --- a/cwmscli/load/root.py +++ b/cwmscli/load/root.py @@ -43,11 +43,39 @@ def _norm_office(o: Optional[str]) -> str: def validate_cda_targets(func): @functools.wraps(func) def wrapper(*args, **kwargs): + source_csv = kwargs.get("source_csv") + target_csv = kwargs.get("target_csv") + + if source_csv and target_csv: + raise click.ClickException( + "--source-csv and --target-csv are both set, but no CDA is involved. " + "Use a plain file copy instead." + ) + + if source_csv: + if kwargs.get("source_cda") and _param_was_explicit("source_cda"): + raise click.ClickException( + "--source-csv and --source-cda are mutually exclusive." + ) + kwargs["source_cda"] = None + + if target_csv: + if kwargs.get("target_cda") and _param_was_explicit("target_cda"): + raise click.ClickException( + "--target-csv and --target-cda are mutually exclusive." + ) + kwargs["target_cda"] = None + source_cda = _normalize_url(kwargs.get("source_cda")) target_cda = _normalize_url(kwargs.get("target_cda")) source_office = _norm_office(kwargs.get("source_office")) target_office = _norm_office(kwargs.get("target_office")) + if source_cda and not source_office: + raise click.ClickException( + "--source-office is required when reading from a source CDA." + ) + same_root = source_cda == target_cda and bool(source_cda) same_office = source_office == target_office and bool(source_office) @@ -64,33 +92,40 @@ def wrapper(*args, **kwargs): "This is allowed, but double-check intent.", ) + src_label = source_csv or source_cda or "-" + tgt_label = target_csv or target_cda or "-" logger.info( - f"Source: {source_cda} (office={source_office or '-'})\n" - f"Target: {target_cda} (office={source_office or '-'})", + f"Source: {src_label} (office={source_office or '-'})\n" + f"Target: {tgt_label} (office={target_office or source_office or '-'})", ) return func(*args, **kwargs) return wrapper +def _param_was_explicit(name: str) -> bool: + ctx = click.get_current_context(silent=True) + if ctx is None: + return False + src = ctx.get_parameter_source(name) + return src is not None and src.name != "DEFAULT" + + def shared_source_target_options(f): f = click.option( "--source-cda", envvar="CDA_SOURCE_URL", - required=True, default="https://cwms-data.usace.army.mil/cwms-data/", help="Source CWMS Data API root. Default: https://cwms-data.usace.army.mil/cwms-data/", )(f) f = click.option( "--source-office", envvar="CDA_SOURCE_OFFICE", - required=True, - help="Source office ID (e.g. SWT, SWL).", + help="Source office ID (e.g. SWT, SWL). Required when reading from a CDA.", )(f) f = click.option( "--target-cda", envvar="CDA_TARGET_URL", - required=True, default="http://localhost:8081/cwms-data/", help="Target CWMS Data API root. Default: http://localhost:8081/cwms-data/", )(f) @@ -115,6 +150,37 @@ def shared_source_target_options(f): return f +def csv_source_target_options(*, allow_source_csv: bool, allow_target_csv: bool): + """Add --source-csv and/or --target-csv to a command, depending on flags.""" + + def decorator(f): + if allow_target_csv: + f = click.option( + "--target-csv", + "target_csv", + type=click.Path(dir_okay=False, writable=True), + default=None, + help=( + "Write fetched locations to this CSV file instead of POSTing " + "to a target CDA. Mutually exclusive with --target-cda." + ), + )(f) + if allow_source_csv: + f = click.option( + "--source-csv", + "source_csv", + type=click.Path(exists=True, dir_okay=False, readable=True), + default=None, + help=( + "Read locations from this CSV file instead of fetching from " + "a source CDA. Mutually exclusive with --source-cda." + ), + )(f) + return f + + return decorator + + @click.group( name="load", help="Load data from one CWMS Data API instance to another.", diff --git a/docs/cli.rst b/docs/cli.rst index acee4c5..74d4be8 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -11,7 +11,7 @@ See also - :doc:`clob ` - :doc:`csv2cwms ` - :doc:`CDA Regex Guide ` -- :doc:`load location ids-all ` +- :doc:`Load Locations ` .. click:: cwmscli.__main__:cli :prog: cwms-cli diff --git a/docs/cli/load_location_ids_all.rst b/docs/cli/load_location_ids_all.rst index 3bb2c18..1c2189d 100644 --- a/docs/cli/load_location_ids_all.rst +++ b/docs/cli/load_location_ids_all.rst @@ -1,18 +1,24 @@ -Load Location ids-all -===================== +Load Locations +============== .. include:: ../_generated/maintainers/load_location_ids_all.inc Use ``cwms-cli load location ids-all`` to copy locations selected by the -source CDA catalog into a target CDA. +source CDA catalog into a target CDA. Use +``cwms-cli load location ids-bygroup`` to copy the locations that belong to a +source CDA location group. -This subcommand passes ``--like`` and ``--location-kind-like`` directly to the -source CDA catalog, so both options use CDA regular expression behavior. The -CLI does not apply extra exact-match filtering. For CDA regex syntax, see the -:doc:`CWMS Data API regular expression guide `. +Both commands can write selected locations to CSV files instead of storing them +to a target CDA. The ``ids-all`` command can also read locations back from a +CSV file and store them to a target CDA. -Examples --------- +The ``ids-all`` command passes ``--like`` and ``--location-kind-like`` directly +to the source CDA catalog, so both options use CDA regular expression behavior. +The CLI does not apply extra exact-match filtering. For CDA regex syntax, see +the :doc:`CWMS Data API regular expression guide `. + +CDA to CDA Examples +------------------- Exact location name: @@ -45,6 +51,85 @@ Match multiple kinds: --like ".*Butte.*" \ --location-kind-like "(PROJECT|STREAM)" +Copy locations from a location group: + +.. code-block:: shell + + cwms-cli load location ids-bygroup \ + --source-cda "https://cwms-data.usace.army.mil/cwms-data/" \ + --source-office SPK \ + --target-cda "http://localhost:8082/cwms-data/" \ + --target-api-key "apikey 0123456789abcdef0123456789abcdef" \ + --group-id "Sacramento River" \ + --category-id Basin \ + --group-office-id SPK \ + --category-office-id SPK + +CSV Files +--------- + +Use ``--target-csv`` when you want to save matching locations to a CSV file +instead of writing them to a target CDA. Use ``--source-csv`` with +``ids-all`` when you want to load locations from a previously exported CSV +file. + +``--target-csv`` is mutually exclusive with ``--target-cda``. ``--source-csv`` +is mutually exclusive with ``--source-cda``. If both the source and target are +CSV files, use a normal file copy instead of ``cwms-cli``. + +Export locations selected by the CDA catalog: + +.. code-block:: shell + + cwms-cli load location ids-all \ + --source-cda "https://cwms-data.usace.army.mil/cwms-data/" \ + --source-office SPK \ + --like "^Black Butte$" \ + --location-kind-like PROJECT \ + --target-csv "black-butte-location.csv" + +Export all resolved members of a location group: + +.. code-block:: shell + + cwms-cli load location ids-bygroup \ + --source-cda "https://cwms-data.usace.army.mil/cwms-data/" \ + --source-office SPK \ + --group-id "Sacramento River" \ + --category-id Basin \ + --group-office-id SPK \ + --category-office-id SPK \ + --target-csv "sacramento-river-locations.csv" + +Load locations from a CSV file into a target CDA: + +.. code-block:: shell + + cwms-cli load location ids-all \ + --source-csv "black-butte-location.csv" \ + --target-cda "http://localhost:8082/cwms-data/" \ + --target-api-key "apikey 0123456789abcdef0123456789abcdef" + +Preview a CSV load without storing records: + +.. code-block:: shell + + cwms-cli load location ids-all \ + --source-csv "black-butte-location.csv" \ + --target-cda "http://localhost:8082/cwms-data/" \ + --dry-run + +CSV Output +---------- + +CSV exports include the location fields returned by CDA, with no added index +column. A small export looks like this: + +.. code-block:: text + + office-id,name,latitude,longitude,active,public-name,location-kind,elevation-units + SPK,Black Butte,39.8006222,-122.3581694,True,Black Butte Lake,PROJECT,ft + Notes ----- @@ -52,3 +137,4 @@ Notes - Use ``.*`` for wildcard-style matching. - Quote regex values in the shell so characters such as ``^``, ``$``, and ``|`` are preserved. - Use the :doc:`CWMS Data API regular expression guide ` when you need CDA-specific regex examples or syntax details. +- Use ``--filter-office`` with ``ids-bygroup`` to keep only group members whose ``office-id`` matches ``--source-office``. This is the default. diff --git a/docs/index.rst b/docs/index.rst index dea66f7..9c4c406 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -26,8 +26,8 @@ Task Guides - :doc:`csv2cwms ` to load CSV time series into CDA - :doc:`Blob commands ` to upload, download, list, delete, and update blobs -- :doc:`Load Location ids-all ` to copy locations - selected from a source CDA catalog into a target CDA +- :doc:`Load Locations ` to copy locations from a + source CDA catalog or location group into a target CDA or CSV file - :doc:`Update command ` to update the installed package with pip - :doc:`Version argument ` to print the installed version and see upgrade guidance diff --git a/tests/commands/test_load_location_ids.py b/tests/commands/test_load_location_ids.py index 08ceba8..a29bbe1 100644 --- a/tests/commands/test_load_location_ids.py +++ b/tests/commands/test_load_location_ids.py @@ -1,6 +1,9 @@ import pandas as pd +import pytest +from click.testing import CliRunner import cwmscli.load.location.location_ids as location_ids_module +from cwmscli.load.location.location import location as location_group def test_load_locations_prefers_saved_token_for_target(monkeypatch): @@ -121,3 +124,145 @@ def store_location(data, fail_if_exists=False): ("get_locations", {"office_id": "SWT", "location_ids": "^LOC_B$"}), ] assert stored == [("LOC_A", False), ("LOC_B", False)] + + +def test_target_csv_writes_locations_and_skips_store(tmp_path, monkeypatch): + monkeypatch.setattr( + "cwmscli.utils.get_saved_login_token", lambda *args, **kwargs: None + ) + stored = [] + + class FakeLocationResponse: + json = [ + {"name": "LOC_A", "office-id": "SWT", "active": True, "latitude": 36.1}, + {"name": "LOC_B", "office-id": "SWT", "active": True, "latitude": 36.2}, + ] + + class FakeCwms: + @staticmethod + def init_session(api_root, api_key=None): + pass + + @staticmethod + def get_locations(**kwargs): + return FakeLocationResponse() + + @staticmethod + def store_location(data, fail_if_exists=False): + stored.append(data["name"]) + + monkeypatch.setattr(location_ids_module, "cwms", FakeCwms) + + out = tmp_path / "out.csv" + location_ids_module.load_locations( + source_cda="https://source.example/cwms-data", + source_office="SWT", + target_cda=None, + target_api_key=None, + verbose=0, + dry_run=False, + like=None, + location_kind_like=["ALL"], + target_csv=str(out), + ) + + assert stored == [] + df = pd.read_csv(out) + assert df["name"].tolist() == ["LOC_A", "LOC_B"] + assert "office-id" in df.columns + # no anonymous index column leaks in + assert "Unnamed: 0" not in df.columns + + +def test_source_csv_reads_and_calls_store_per_row(tmp_path, monkeypatch): + monkeypatch.setattr( + "cwmscli.utils.get_saved_login_token", lambda *args, **kwargs: "saved-token" + ) + stored = [] + + src = tmp_path / "in.csv" + pd.DataFrame( + [ + {"name": "FROM_CSV_A", "office-id": "SWT", "active": True, "latitude": 1.0}, + {"name": "FROM_CSV_B", "office-id": "SWT", "active": True, "latitude": 2.0}, + ] + ).to_csv(src, index=False) + + class FakeCwms: + @staticmethod + def init_session(api_root, api_key=None, token=None): + pass + + @staticmethod + def store_location(data, fail_if_exists=False): + stored.append((data["name"], data["active"], data.get("latitude"))) + + monkeypatch.setattr(location_ids_module, "cwms", FakeCwms) + + location_ids_module.load_locations( + source_cda=None, + source_office=None, + target_cda="http://localhost:8082/cwms-data", + target_api_key=None, + verbose=0, + dry_run=False, + like=None, + location_kind_like=["ALL"], + source_csv=str(src), + ) + + assert stored == [ + ("FROM_CSV_A", True, 1.0), + ("FROM_CSV_B", True, 2.0), + ] + + +def test_cli_rejects_source_csv_and_target_csv_together(tmp_path, monkeypatch): + monkeypatch.setattr( + "cwmscli.utils.get_saved_login_token", lambda *args, **kwargs: None + ) + src = tmp_path / "in.csv" + src.write_text("name,office-id,active\nLOC_A,SWT,True\n") + out = tmp_path / "out.csv" + + runner = CliRunner() + result = runner.invoke( + location_group, + [ + "ids-all", + "--source-csv", + str(src), + "--target-csv", + str(out), + ], + ) + assert result.exit_code != 0 + assert ( + "no CDA is involved" in result.output or "mutually exclusive" in result.output + ) + + +def test_cli_rejects_source_csv_with_explicit_source_cda(tmp_path, monkeypatch): + monkeypatch.setattr( + "cwmscli.utils.get_saved_login_token", lambda *args, **kwargs: None + ) + src = tmp_path / "in.csv" + src.write_text("name,office-id,active\nLOC_A,SWT,True\n") + + runner = CliRunner() + result = runner.invoke( + location_group, + [ + "ids-all", + "--source-csv", + str(src), + "--source-cda", + "https://override.example/cwms-data", + "--target-cda", + "http://localhost:8082/cwms-data", + "--target-api-key", + "k", + ], + ) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output diff --git a/tests/commands/test_load_location_ids_bygroup.py b/tests/commands/test_load_location_ids_bygroup.py index 77f09ef..9ee30fc 100644 --- a/tests/commands/test_load_location_ids_bygroup.py +++ b/tests/commands/test_load_location_ids_bygroup.py @@ -83,3 +83,71 @@ def store_location(data, fail_if_exists=False): ) ] assert not [call for call in calls if call[0] == "store_location"] + + +def test_copy_from_group_target_csv_writes_and_skips_store(tmp_path, monkeypatch): + monkeypatch.setattr( + "cwmscli.utils.get_saved_login_token", lambda *args, **kwargs: None + ) + calls = [] + + class FakeGroupResponse: + df = pd.DataFrame( + [ + {"location-id": "Black Butte-Outflow", "office-id": "SPK"}, + {"location-id": "Black Butte-Pool", "office-id": "SPK"}, + ] + ) + + class FakeLocationsResponse: + df = pd.DataFrame( + [{"name": "Black Butte-Outflow"}, {"name": "Black Butte-Pool"}] + ) + json = [ + {"name": "Black Butte-Outflow", "office-id": "SPK", "active": True}, + {"name": "Black Butte-Pool", "office-id": "SPK", "active": True}, + ] + + class FakeCwms: + @staticmethod + def init_session(api_root, api_key=None): + calls.append(("init_session", api_root, api_key)) + + @staticmethod + def get_location_group(**kwargs): + return FakeGroupResponse() + + @staticmethod + def get_locations(**kwargs): + return FakeLocationsResponse() + + @staticmethod + def store_location(data, fail_if_exists=False): + calls.append(("store_location", data["name"])) + + monkeypatch.setattr(location_ids_bygroup_module, "cwms", FakeCwms) + + out = tmp_path / "group.csv" + location_ids_bygroup_module.copy_from_group( + source_cda="https://source.example/cwms-data", + source_office="SPK", + target_cda=None, + target_api_key=None, + verbose=0, + group_id="Sacramento River", + category_id="Basin", + group_office_id="SPK", + category_office_id="SPK", + filter_office=True, + dry_run=False, + target_csv=str(out), + ) + + assert not [c for c in calls if c[0] == "store_location"] + df = pd.read_csv(out) + assert df["name"].tolist() == ["Black Butte-Outflow", "Black Butte-Pool"] + assert "Unnamed: 0" not in df.columns + # only source CDA was initialized — no second init_session for target + init_calls = [c for c in calls if c[0] == "init_session"] + assert len(init_calls) == 1 + assert init_calls[0][1] == "https://source.example/cwms-data"