diff --git a/.agents/issue-48-ledger.yml b/.agents/issue-48-ledger.yml new file mode 100644 index 00000000..02876655 --- /dev/null +++ b/.agents/issue-48-ledger.yml @@ -0,0 +1,506 @@ +version: 1 +issue: 48 +base: main +branch: codex/issue-48 +tasks: + - id: task-01 + title: 'Create `config/name_registry.yml` with fields: `canonical_key`, `aliases`, + `display_name`, and optional per-variant `series_included` flags' + status: done + started_at: '2026-02-22T21:31:34Z' + finished_at: '2026-02-22T21:31:48Z' + commit: 8cbb63a5d9bdf9e8ef45572cd1c98960ad5331d5 + notes: [] + - id: task-02 + title: 'Create the `config/name_registry.yml` file with basic YAML structure (verify: + config validated)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-03 + title: 'Create the `config/name_registry.yml` file with top-level schema definition + (verify: config validated)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-04 + title: 'Define the `canonical_key` field specification including format requirements + (verify: formatter passes)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-05 + title: 'Define the `canonical_key` field specification including uniqueness constraints + (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-06 + title: 'Define scope for: Define the `aliases` field specification as a list type + with validation rules for duplicate detection (verify: confirm completion in + repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-07 + title: 'Implement focused slice for: Define the `aliases` field specification + as a list type with validation rules for duplicate detection (verify: confirm + completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-08 + title: 'Validate focused slice for: Define the `aliases` field specification as + a list type with validation rules for duplicate detection (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-09 + title: 'Define the `display_name` field specification with character limits (verify: + confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-10 + title: 'Define the `display_name` field specification with formatting guidelines + (verify: formatter passes)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-11 + title: 'Define the optional `series_included` flags structure with per-variant + boolean logic (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-12 + title: 'Define the optional `series_included` flags structure with default behavior + (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-13 + title: 'Add inline YAML comments documenting each field''s purpose (verify: confirm + completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-14 + title: 'expected values (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-15 + title: 'Create unit tests validating the registry file can be parsed (verify: + tests pass)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-16 + title: 'loaded correctly (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-17 + title: Update name normalization logic to consult `config/name_registry.yml` first, + then fall back to existing hardcoded mappings + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-18 + title: 'Create a registry loader module that parses (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-19 + title: 'validates the `config/name_registry.yml` file on startup (verify: config + validated)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-20 + title: 'Implement a lookup function that searches the registry by canonical key + (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-21 + title: 'aliases (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-22 + title: 'Define scope for: Refactor existing name normalization logic to call the + registry lookup function first (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-23 + title: 'Implement focused slice for: Refactor existing name normalization logic + to call the registry lookup function first (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-24 + title: 'Validate focused slice for: Refactor existing name normalization logic + to call the registry lookup function first (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-25 + title: 'Define scope for: Implement fallback logic that uses existing hardcoded + mappings when registry lookup returns no match (verify: confirm completion in + repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-26 + title: 'Implement focused slice for: Implement fallback logic that uses existing + hardcoded mappings when registry lookup returns no match (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-27 + title: 'Validate focused slice for: Implement fallback logic that uses existing + hardcoded mappings when registry lookup returns no match (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-28 + title: 'Define scope for: Add error handling for registry file missing or malformed + scenarios with appropriate fallback behavior (verify: confirm completion in + repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-29 + title: 'Implement focused slice for: Add error handling for registry file missing + or malformed scenarios with appropriate fallback behavior (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-30 + title: 'Validate focused slice for: Add error handling for registry file missing + or malformed scenarios with appropriate fallback behavior (verify: confirm completion + in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-31 + title: 'Create unit tests verifying registry-first lookup with fallback to hardcoded + mappings (verify: tests pass)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-32 + title: 'Add integration tests confirming backward compatibility with existing + normalization behavior (verify: tests pass)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-33 + title: "Implement a developer-only maintainer command to generate a monthly \u201C\ + mapping diff report\u201D that lists: new names not in the registry, names that\ + \ mapped via fallback, and suggested canonicalization" + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-34 + title: Write documentation describing safe, manual, reviewed steps to add a new + counterparty series to historical workbooks + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-35 + title: 'Define scope for: Document the step-by-step process for adding a new entry + to the `config/name_registry.yml` file with validation checks (verify: config + validated)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-36 + title: 'Implement focused slice for: Document the step-by-step process for adding + a new entry to the `config/name_registry.yml` file with validation checks (verify: + config validated)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-37 + title: 'Validate focused slice for: Document the step-by-step process for adding + a new entry to the `config/name_registry.yml` file with validation checks (verify: + config validated)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-38 + title: 'Define scope for: Document the manual procedure for adding new series + headers to historical workbook templates without breaking existing formulas + (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-39 + title: 'Implement focused slice for: Document the manual procedure for adding + new series headers to historical workbook templates without breaking existing + formulas (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-40 + title: 'Validate focused slice for: Document the manual procedure for adding new + series headers to historical workbook templates without breaking existing formulas + (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-41 + title: 'Define scope for: Document the validation steps to verify that updated + mappings correctly resolve historical transaction data' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-42 + title: 'Implement focused slice for: Document the validation steps to verify that + updated mappings correctly resolve historical transaction data' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-43 + title: 'Validate focused slice for: Document the validation steps to verify that + updated mappings correctly resolve historical transaction data' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-44 + title: 'Document the review checklist maintainers should complete before committing + registry changes (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-45 + title: 'Document the rollback procedure if a registry update causes unexpected + reconciliation issues (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-46 + title: 'Create example scenarios showing common counterparty addition workflows + with before (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-47 + title: 'Create example scenarios showing common counterparty addition workflows + with after states (verify: confirm completion in repo)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-48 + title: "Running the monthly \u201Cmapping diff report\u201D command produces an\ + \ explicit report listing new names not present in `config/name_registry.yml`" + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-49 + title: After updating `config/name_registry.yml`, re-running the reconciliation/normalization + flow produces no warnings for previously reported names and the report no longer + lists them + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-50 + title: 'Add `config/name_registry.yml`:' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-51 + title: canonical_key + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-52 + title: aliases + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-53 + title: display_name + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-54 + title: "optional \u201Cseries_included\u201D flags per variant" + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-55 + title: Update normalization to consult registry first, then fallback to hardcoded + mappings + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-56 + title: "Add a maintainer command (developer-only) to generate a \u201Cmapping\ + \ diff report\u201D for the month:" + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-57 + title: new names not in registry + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-58 + title: names that mapped via fallback + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-59 + title: suggested canonicalization + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-60 + title: 'Add docs: safe steps to add a new counterparty series to historical workbooks + (manual, reviewed)' + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-61 + title: The system produces an explicit report when new names appear + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] + - id: task-62 + title: Maintainers can update the registry and re-run to clear reconciliation + warnings + status: todo + started_at: null + finished_at: null + commit: '' + notes: [] diff --git a/config/name_registry.yml b/config/name_registry.yml new file mode 100644 index 00000000..891dc282 --- /dev/null +++ b/config/name_registry.yml @@ -0,0 +1,84 @@ +# Top-level schema: +# - schema_version: required integer, currently must be 1. +# - entries: required non-empty list of registry entry objects. +# Schema version for forward-compatible validation rules. +schema_version: 1 + +# Canonical counterparty/series registry used for deterministic name normalization. +entries: + - canonical_key: citibank # Required snake_case key: ^[a-z0-9]+(?:_[a-z0-9]+)*$, unique across entries. + display_name: Citibank # Required canonical series/header label (1-80 chars, workbook-safe punctuation). + aliases: # Required list[str], at least one value, deduplicated case-insensitively after whitespace normalization. + - Citibank + - Citigroup + # Optional per-variant include flags. If omitted, defaults to included for all variants. + series_included: + all_programs: true + ex_trend: true + trend: true + + - canonical_key: bank_of_america # canonical_key values must remain globally unique. + display_name: Bank of America + aliases: + - Bank of America + - Bank of America, NA + - Bank of America NA + + - canonical_key: goldman_sachs + display_name: Goldman Sachs + aliases: + - Goldman Sachs + - Goldman Sachs Int'l + + - canonical_key: soc_gen + display_name: Soc Gen + aliases: + - Soc Gen + - Societe Generale + + - canonical_key: barclays + display_name: Barclays + aliases: + - Barclays + - Barclays Bank PLC + + - canonical_key: cme + display_name: CME + aliases: + - CME + - CME Clearing House + + - canonical_key: ice + display_name: ICE + aliases: + - ICE + - ICE Clear U.S. + - ICE Clear US + + - canonical_key: ice_euro + display_name: ICE Euro + aliases: + - ICE Euro + - ICE Clear Europe + series_included: + all_programs: true + ex_trend: true + trend: false + + - canonical_key: eurex + display_name: EUREX + aliases: + - EUREX + - EUREX Clearing + + - canonical_key: japan_scc + display_name: Japan SCC + aliases: + - Japan SCC + - Japan Securities Clearing Corporation + + - canonical_key: korea_exchange + display_name: Korea Exchange + aliases: + - Korea Exchange + - Korea Exchange (in-house) diff --git a/pyproject.toml b/pyproject.toml index ffeee0ab..42f7e260 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,6 +131,10 @@ disable_error_code = ["import-untyped"] module = "counter_risk.mosers.workbook_generation" disable_error_code = ["import-untyped"] +[[tool.mypy.overrides]] +module = "counter_risk.name_registry" +disable_error_code = ["import-untyped"] + [[tool.mypy.overrides]] module = "scripts.*" ignore_errors = true diff --git a/src/counter_risk/name_registry.py b/src/counter_risk/name_registry.py new file mode 100644 index 00000000..30aaa173 --- /dev/null +++ b/src/counter_risk/name_registry.py @@ -0,0 +1,139 @@ +"""Name registry parsing and validation helpers.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any, Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator + +_CANONICAL_KEY_PATTERN = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$") + + +def _normalize_alias_token(value: str) -> str: + return " ".join(value.split()).casefold() + + +class SeriesIncludedFlags(BaseModel): + """Optional per-variant inclusion flags for a canonical series.""" + + model_config = ConfigDict(extra="forbid") + + all_programs: bool = True + ex_trend: bool = True + trend: bool = True + + +class NameRegistryEntry(BaseModel): + """A single canonical mapping entry.""" + + model_config = ConfigDict(extra="forbid") + + canonical_key: str + aliases: list[str] = Field(min_length=1) + display_name: str = Field(min_length=1, max_length=80) + series_included: SeriesIncludedFlags | None = None + + @field_validator("canonical_key") + @classmethod + def _validate_canonical_key(cls, value: str) -> str: + if not _CANONICAL_KEY_PATTERN.fullmatch(value): + raise ValueError( + "canonical_key must match ^[a-z0-9]+(?:_[a-z0-9]+)*$ (snake_case lowercase)." + ) + return value + + @field_validator("aliases") + @classmethod + def _validate_aliases(cls, aliases: list[str]) -> list[str]: + normalized_seen: set[str] = set() + normalized_aliases: list[str] = [] + + for alias in aliases: + if not isinstance(alias, str): + raise ValueError("aliases entries must be strings.") + normalized = " ".join(alias.split()) + if not normalized: + raise ValueError("aliases cannot contain blank values.") + dedupe_key = normalized.casefold() + if dedupe_key in normalized_seen: + raise ValueError( + f"aliases contains duplicate value after normalization: {normalized!r}" + ) + normalized_seen.add(dedupe_key) + normalized_aliases.append(normalized) + return normalized_aliases + + @field_validator("display_name") + @classmethod + def _validate_display_name(cls, value: str) -> str: + normalized = " ".join(value.split()) + if not normalized: + raise ValueError("display_name cannot be blank.") + return normalized + + +class NameRegistryConfig(BaseModel): + """Top-level registry schema.""" + + model_config = ConfigDict(extra="forbid") + + schema_version: Literal[1] + entries: list[NameRegistryEntry] = Field(min_length=1) + + @model_validator(mode="after") + def _validate_global_uniqueness(self) -> NameRegistryConfig: + canonical_keys: set[str] = set() + alias_index: dict[str, str] = {} + + for entry in self.entries: + if entry.canonical_key in canonical_keys: + raise ValueError(f"Duplicate canonical_key found: {entry.canonical_key!r}") + canonical_keys.add(entry.canonical_key) + + for alias in entry.aliases: + alias_token = _normalize_alias_token(alias) + existing = alias_index.get(alias_token) + if existing is None: + alias_index[alias_token] = entry.canonical_key + continue + if existing != entry.canonical_key: + raise ValueError( + "Alias collision across entries: " + f"{alias!r} maps to both {existing!r} and {entry.canonical_key!r}" + ) + return self + + +def _format_registry_validation_error(error: ValidationError) -> str: + lines = ["Name registry validation failed:"] + for issue in error.errors(): + location = ".".join(str(part) for part in issue.get("loc", ())) + message = issue.get("msg", "Invalid value") + lines.append(f"- {location}: {message}") + return "\n".join(lines) + + +def load_name_registry(path: str | Path = Path("config/name_registry.yml")) -> NameRegistryConfig: + """Load and validate a name registry YAML file from disk.""" + + config_path = Path(path) + try: + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except OSError as exc: + raise ValueError(f"Unable to read name registry file '{config_path}': {exc}") from exc + except yaml.YAMLError as exc: + raise ValueError(f"Invalid YAML in name registry file '{config_path}': {exc}") from exc + + data: Any = raw if raw is not None else {} + if not isinstance(data, dict): + raise ValueError( + f"Name registry file '{config_path}' must contain a top-level mapping/object." + ) + + try: + return NameRegistryConfig.model_validate(data) + except ValidationError as exc: + raise ValueError(_format_registry_validation_error(exc)) from exc diff --git a/src/counter_risk/parsers/exposure_maturity_schedule.py b/src/counter_risk/parsers/exposure_maturity_schedule.py index fb2ac1d6..4acd2d16 100644 --- a/src/counter_risk/parsers/exposure_maturity_schedule.py +++ b/src/counter_risk/parsers/exposure_maturity_schedule.py @@ -134,8 +134,7 @@ def _find_header_row_and_map( missing_text = ", ".join(best_missing) raise ExposureMaturityColumnsMissingError( - "Missing required headers in exposure maturity worksheet within scan range: " - f"{missing_text}" + f"Missing required headers in exposure maturity worksheet within scan range: {missing_text}" ) diff --git a/src/counter_risk/writers/historical_update.py b/src/counter_risk/writers/historical_update.py index 034cd46b..b1cca034 100644 --- a/src/counter_risk/writers/historical_update.py +++ b/src/counter_risk/writers/historical_update.py @@ -467,8 +467,7 @@ def _validate_preserved_wal_cells( ) if expected.value != value: raise WorkbookValidationError( - "WAL append changed existing cell value at " - f"row={row_index} column={column_index}" + f"WAL append changed existing cell value at row={row_index} column={column_index}" ) if expected.number_format != getattr(cell, "number_format", None): raise WorkbookValidationError( @@ -708,9 +707,9 @@ def append_wal_row( columns=tuple(range(1, preserve_through_column + 1)), ) - worksheet.cell(row=append_target.append_row, column=append_target.date_column).value = ( - px_date - ) + worksheet.cell( + row=append_target.append_row, column=append_target.date_column + ).value = px_date worksheet.cell(row=append_target.append_row, column=append_target.wal_column).value = float( wal_value ) diff --git a/tests/test_fixtures_smoke.py b/tests/test_fixtures_smoke.py index a63dac6e..6bfc5531 100644 --- a/tests/test_fixtures_smoke.py +++ b/tests/test_fixtures_smoke.py @@ -54,9 +54,9 @@ def test_fixture_workbooks_and_presentations_open() -> None: and path.name not in already_validated_fixture_names ) assert fixture_paths, f"No .pptx/.xlsx fixtures found under {fixtures_root}." - assert ( - len(fixture_paths) >= 10 - ), "Expected representative fixture inventory under tests/fixtures." + assert len(fixture_paths) >= 10, ( + "Expected representative fixture inventory under tests/fixtures." + ) workbook_fixtures = [path for path in fixture_paths if path.suffix.lower() == ".xlsx"] presentation_fixtures = [path for path in fixture_paths if path.suffix.lower() == ".pptx"] diff --git a/tests/test_name_registry.py b/tests/test_name_registry.py new file mode 100644 index 00000000..432e3fa5 --- /dev/null +++ b/tests/test_name_registry.py @@ -0,0 +1,197 @@ +"""Unit tests for name registry parsing and validation.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from counter_risk.name_registry import NameRegistryConfig, load_name_registry + + +def test_load_name_registry_default_config() -> None: + registry = load_name_registry() + + assert isinstance(registry, NameRegistryConfig) + assert registry.schema_version == 1 + assert registry.entries + assert all(entry.canonical_key for entry in registry.entries) + assert all(entry.display_name for entry in registry.entries) + assert all(entry.aliases for entry in registry.entries) + + +def test_load_name_registry_includes_optional_series_flags() -> None: + registry = load_name_registry(Path("config/name_registry.yml")) + + ice_euro = next(entry for entry in registry.entries if entry.canonical_key == "ice_euro") + assert ice_euro.series_included is not None + assert ice_euro.series_included.all_programs is True + assert ice_euro.series_included.ex_trend is True + assert ice_euro.series_included.trend is False + + bank_of_america = next( + entry for entry in registry.entries if entry.canonical_key == "bank_of_america" + ) + assert bank_of_america.series_included is None + + +@pytest.mark.parametrize( + "canonical_key", + [ + "Invalid Key", + "invalid-key", + "_leading", + "trailing_", + "two__underscores", + "MixedCase", + "contains.dot", + ], +) +def test_load_name_registry_rejects_invalid_canonical_key( + tmp_path: Path, canonical_key: str +) -> None: + config_path = tmp_path / "name_registry.yml" + config_path.write_text( + "\n".join( + [ + "schema_version: 1", + "entries:", + f" - canonical_key: {canonical_key}", + " display_name: Valid Name", + " aliases:", + " - Valid Name", + ] + ) + + "\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="Name registry validation failed"): + load_name_registry(config_path) + + +@pytest.mark.parametrize( + "canonical_key", + ["a", "bank_of_america", "cme2", "name_1", "counterparty_v2_key"], +) +def test_load_name_registry_accepts_valid_canonical_key(tmp_path: Path, canonical_key: str) -> None: + config_path = tmp_path / "name_registry.yml" + config_path.write_text( + "\n".join( + [ + "schema_version: 1", + "entries:", + f" - canonical_key: {canonical_key}", + " display_name: Valid Name", + " aliases:", + " - Valid Name", + ] + ) + + "\n", + encoding="utf-8", + ) + + registry = load_name_registry(config_path) + + assert registry.entries[0].canonical_key == canonical_key + + +def test_load_name_registry_rejects_duplicate_aliases_in_entry(tmp_path: Path) -> None: + config_path = tmp_path / "name_registry.yml" + config_path.write_text( + "\n".join( + [ + "schema_version: 1", + "entries:", + " - canonical_key: sample_name", + " display_name: Sample Name", + " aliases:", + " - Sample Name", + " - ' sample name '", + ] + ) + + "\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="Name registry validation failed"): + load_name_registry(config_path) + + +def test_load_name_registry_rejects_duplicate_aliases_across_entries(tmp_path: Path) -> None: + config_path = tmp_path / "name_registry.yml" + config_path.write_text( + "\n".join( + [ + "schema_version: 1", + "entries:", + " - canonical_key: first_name", + " display_name: First Name", + " aliases:", + " - Shared Alias", + " - canonical_key: second_name", + " display_name: Second Name", + " aliases:", + " - shared alias", + ] + ) + + "\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="Name registry validation failed"): + load_name_registry(config_path) + + +def test_load_name_registry_rejects_duplicate_canonical_keys(tmp_path: Path) -> None: + config_path = tmp_path / "name_registry.yml" + config_path.write_text( + "\n".join( + [ + "schema_version: 1", + "entries:", + " - canonical_key: duplicate_name", + " display_name: Duplicate Name One", + " aliases:", + " - Duplicate Name One", + " - canonical_key: duplicate_name", + " display_name: Duplicate Name Two", + " aliases:", + " - Duplicate Name Two", + ] + ) + + "\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="Duplicate canonical_key found"): + load_name_registry(config_path) + + +def test_load_name_registry_rejects_unsupported_schema_version(tmp_path: Path) -> None: + config_path = tmp_path / "name_registry.yml" + config_path.write_text( + "\n".join( + [ + "schema_version: 2", + "entries:", + " - canonical_key: sample_name", + " display_name: Sample Name", + " aliases:", + " - Sample Name", + ] + ) + + "\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="Name registry validation failed"): + load_name_registry(config_path) + + +def test_load_name_registry_rejects_missing_top_level_entries(tmp_path: Path) -> None: + config_path = tmp_path / "name_registry.yml" + config_path.write_text("schema_version: 1\n", encoding="utf-8") + + with pytest.raises(ValueError, match="Name registry validation failed"): + load_name_registry(config_path) diff --git a/tests/test_nisa_all_programs_parser.py b/tests/test_nisa_all_programs_parser.py index 26dea20f..c00c1526 100644 --- a/tests/test_nisa_all_programs_parser.py +++ b/tests/test_nisa_all_programs_parser.py @@ -69,9 +69,9 @@ def _build_raw_nisa_workbook( for offset, header_name in enumerate(header_order, start=2): header_columns[header_name] = offset worksheet.cell(row=header_row, column=offset).value = _HEADER_LABELS[header_name] - worksheet.cell(row=header_row - 1, column=header_columns["annualized_volatility"]).value = ( - "Annualized Volatility" - ) + worksheet.cell( + row=header_row - 1, column=header_columns["annualized_volatility"] + ).value = "Annualized Volatility" segment_column = 1 first_ch_row = header_row + 2 @@ -263,9 +263,9 @@ def _build_raw_nisa_workbook( header_name="annualized_volatility", value=0.11, ) - worksheet.cell(row=totals_marker_row + 2, column=header_columns["counterparty"]).value = ( - "Total Current Exposure" - ) + worksheet.cell( + row=totals_marker_row + 2, column=header_columns["counterparty"] + ).value = "Total Current Exposure" path.parent.mkdir(parents=True, exist_ok=True) workbook.save(path) diff --git a/tests/test_nisa_parser.py b/tests/test_nisa_parser.py index 1846e424..76dd48d5 100644 --- a/tests/test_nisa_parser.py +++ b/tests/test_nisa_parser.py @@ -120,9 +120,9 @@ def _populate_minimal_nisa_sheet( first_data_row = header_row + 2 worksheet.cell(row=first_data_row, column=1).value = "Swaps" if "counterparty" in header_columns: - worksheet.cell(row=first_data_row, column=header_columns["counterparty"]).value = ( - counterparty_name - ) + worksheet.cell( + row=first_data_row, column=header_columns["counterparty"] + ).value = counterparty_name for numeric_header in ( "cash", "tips", @@ -139,15 +139,15 @@ def _populate_minimal_nisa_sheet( totals_marker_row = 20 if "counterparty" in header_columns: - worksheet.cell(row=totals_marker_row, column=header_columns["counterparty"]).value = ( - "Total by Counterparty/Clearing House" - ) - worksheet.cell(row=totals_marker_row + 1, column=header_columns["counterparty"]).value = ( - counterparty_name - ) - worksheet.cell(row=totals_marker_row + 2, column=header_columns["counterparty"]).value = ( - "Total Current Exposure" - ) + worksheet.cell( + row=totals_marker_row, column=header_columns["counterparty"] + ).value = "Total by Counterparty/Clearing House" + worksheet.cell( + row=totals_marker_row + 1, column=header_columns["counterparty"] + ).value = counterparty_name + worksheet.cell( + row=totals_marker_row + 2, column=header_columns["counterparty"] + ).value = "Total Current Exposure" for numeric_header in ( "tips", diff --git a/tests/test_runner_launch.py b/tests/test_runner_launch.py index 7434f35a..70cda254 100644 --- a/tests/test_runner_launch.py +++ b/tests/test_runner_launch.py @@ -15,9 +15,9 @@ @pytest.fixture -def filesystem_and_explorer_stubs() -> ( - tuple[set[str], list[str], Callable[[str], bool], Callable[[str], int]] -): +def filesystem_and_explorer_stubs() -> tuple[ + set[str], list[str], Callable[[str], bool], Callable[[str], int] +]: existing_directories: set[str] = set() opened_directories: list[str] = [] diff --git a/tests/test_validate_release_workflow_yaml.py b/tests/test_validate_release_workflow_yaml.py index 4a410cc4..ae9d5f22 100644 --- a/tests/test_validate_release_workflow_yaml.py +++ b/tests/test_validate_release_workflow_yaml.py @@ -41,7 +41,8 @@ def _write_workflow(path: Path, *, extra: str = "") -> None: name: release-${{ env.RELEASE_VERSION }} path: release/${{ env.RELEASE_VERSION }}/ retention-days: 7 -""" + extra, +""" + + extra, encoding="utf-8", ) diff --git a/tests/test_vba_runnerlaunch_signature.py b/tests/test_vba_runnerlaunch_signature.py index 07be1576..a346bad9 100644 --- a/tests/test_vba_runnerlaunch_signature.py +++ b/tests/test_vba_runnerlaunch_signature.py @@ -31,15 +31,15 @@ def test_buildcommand_signature_has_exactly_three_parameters() -> None: r"Sub\s+BuildCommand\s*\([^,]+,[^,]+,[^,]+\)", flags=re.IGNORECASE | re.DOTALL, ) - assert ( - signature_pattern.search(normalized_source) is not None - ), "BuildCommand signature must contain exactly three parameters." + assert signature_pattern.search(normalized_source) is not None, ( + "BuildCommand signature must contain exactly three parameters." + ) def test_buildcommand_contains_date_parsing_call() -> None: source = _load_runnerlaunch_source() body = _extract_buildcommand_body(source) date_pattern = re.compile(r"(CDate|DateValue|DateSerial)\s*\(", flags=re.IGNORECASE) - assert ( - date_pattern.search(body) is not None - ), "BuildCommand body must contain CDate, DateValue, or DateSerial date parsing." + assert date_pattern.search(body) is not None, ( + "BuildCommand body must contain CDate, DateValue, or DateSerial date parsing." + )