diff --git a/pyproject.toml b/pyproject.toml index 80f4ba0..4163218 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,8 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "black>=25.9.0", + "click>=8.1", + "pyyaml>=6.0", "requests>=2.32.5", "filelock", "deepdiff>=8.6.1", @@ -18,6 +20,9 @@ dependencies = [ [project.urls] Repository = "https://github.com/TranslatorSRI/babel-validation" +[project.scripts] +csv-to-babeltests = "src.babel_validation.tools.csv_to_babeltests:main" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/src/babel_validation/tools/__init__.py b/src/babel_validation/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/babel_validation/tools/csv_to_babeltests.py b/src/babel_validation/tools/csv_to_babeltests.py new file mode 100644 index 0000000..565f23f --- /dev/null +++ b/src/babel_validation/tools/csv_to_babeltests.py @@ -0,0 +1,434 @@ +"""csv-to-babeltests +==================== + +Convert a CSV of (CURIE, label/type/equivalent-CURIE) rows into a YAML +``babel_tests:`` block suitable for pasting into a GitHub issue, optionally +validating each row against a NodeNorm endpoint defined in +``tests/targets.ini``. + +The emitted YAML is the same format consumed by +``GitHubIssuesTestCases.get_test_issues_from_issue`` — assertion handlers and +YAML schema are reused, not duplicated. + +Run: + uv run csv-to-babeltests INPUT.csv --curie-column OutputID \\ + --label-column "Expected Result / Suggested Comparator" --target dev +""" + +from __future__ import annotations + +import configparser +import csv +import io +import sys +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path + +import click +import yaml + +from src.babel_validation.assertions import ASSERTION_HANDLERS +from src.babel_validation.core.testrow import TestStatus +from src.babel_validation.services.nodenorm import CachedNodeNorm + + +# --- YAML emission --------------------------------------------------------- + +class _FlowList(list): + """List subclass that _BabelTestDumper emits in inline flow style.""" + + +def _represent_flow_list(dumper, data): + return dumper.represent_sequence( + "tag:yaml.org,2002:seq", data, flow_style=True + ) + + +class _BabelTestDumper(yaml.SafeDumper): + """Module-local dumper so the _FlowList representer doesn't mutate the global + yaml.SafeDumper (which would change YAML output process-wide for other code).""" + + +_BabelTestDumper.add_representer(_FlowList, _represent_flow_list) + + +# --- Data structures ------------------------------------------------------- + +@dataclass +class BlockEntry: + """One assertion invocation: a param_set with provenance back to a CSV row.""" + row_idx: int # 1-based; header is row 1, first data row is row 2. + param_set: list[str] + + +@dataclass +class ValidationResult: + assertion: str + row_idx: int + param_set: list[str] + status: TestStatus + messages: list[str] = field(default_factory=list) + + +# --- Pure functions (testable without network) ----------------------------- + +def read_csv(path: Path | str, delimiter: str | None = None) -> list[dict[str, str]]: + """Read a CSV file (or '-' for stdin) into a list of dict rows. + + If ``delimiter`` is None we let csv.Sniffer guess from the first 4KiB, + falling back to ',' on failure. + """ + if str(path) == "-": + text = sys.stdin.read() + else: + text = Path(path).read_text(encoding="utf-8-sig") # strip any BOM + + if delimiter is None: + try: + delimiter = csv.Sniffer().sniff(text[:4096], delimiters=",\t;|").delimiter + except csv.Error: + delimiter = "," + + reader = csv.DictReader(io.StringIO(text), delimiter=delimiter) + return list(reader) + + +def build_blocks( + rows: list[dict[str, str]], + *, + curie_column: str, + label_column: str | None, + type_column: str | None, + equivalent_curie_column: str | None, + emit_resolves: bool, + dedupe: bool, + skip_empty: bool, +) -> tuple[dict[str, list[BlockEntry]], list[str]]: + """Turn CSV rows into ``{assertion_name: [BlockEntry, ...]}``. + + Returns ``(blocks, warnings)``. Warnings is a list of human-readable + strings the caller can dump to stderr (e.g. "row 17: empty OutputID"). + """ + blocks: dict[str, list[BlockEntry]] = defaultdict(list) + warnings: list[str] = [] + seen: dict[str, set[tuple[str, ...]]] = defaultdict(set) + + def add(assertion: str, param_set: list[str], row_idx: int) -> None: + key = tuple(param_set) + if dedupe and key in seen[assertion]: + return + seen[assertion].add(key) + blocks[assertion].append(BlockEntry(row_idx=row_idx, param_set=param_set)) + + for offset, row in enumerate(rows): + row_idx = offset + 2 # match what spreadsheet UIs show: header is row 1. + + curie = (row.get(curie_column) or "").strip() + if not curie: + warnings.append(f"row {row_idx}: empty {curie_column!r} — skipping") + continue + + if label_column is not None: + label = (row.get(label_column) or "").strip() + if not label and skip_empty: + warnings.append(f"row {row_idx}: empty {label_column!r} — skipping HasLabel") + else: + add("HasLabel", [curie, label], row_idx) + + if type_column is not None: + biolink_type = (row.get(type_column) or "").strip() + if not biolink_type and skip_empty: + warnings.append(f"row {row_idx}: empty {type_column!r} — skipping ResolvesWithType") + else: + add("ResolvesWithType", [biolink_type, curie], row_idx) + + if equivalent_curie_column is not None: + equiv = (row.get(equivalent_curie_column) or "").strip() + if not equiv and skip_empty: + warnings.append(f"row {row_idx}: empty {equivalent_curie_column!r} — skipping ResolvesWith") + else: + add("ResolvesWith", [curie, equiv], row_idx) + + if emit_resolves: + add("Resolves", [curie], row_idx) + + return dict(blocks), warnings + + +def emit_yaml( + blocks: dict[str, list[BlockEntry]], + *, + fence: bool = True, + header: str | None = None, +) -> str: + """Render a ``{assertion_name: [BlockEntry, ...]}`` map as a YAML block. + + Each param_set is emitted as an inline flow list so the output reads like + the existing examples in ``assertions/nodenorm.py``:: + + babel_tests: + HasLabel: + - [CHEBI:15365, aspirin] + + A round-trip self-check via ``yaml.safe_load`` ensures we never emit + something the GitHub-issue parser would reject. + """ + # Match the convention used in src/babel_validation/assertions/nodenorm.py: + # single-element param_sets are emitted as bare strings ("- CHEBI:15365"), + # multi-element ones as inline flow lists ("- [CHEBI:15365, aspirin]"). + # Both parse to the same param_set via the GitHub issue loader. + data = { + "babel_tests": { + assertion: [ + e.param_set[0] if len(e.param_set) == 1 else _FlowList(e.param_set) + for e in entries + ] + for assertion, entries in blocks.items() + } + } + body = yaml.dump( + data, + Dumper=_BabelTestDumper, + sort_keys=False, + default_flow_style=False, + allow_unicode=True, + width=10_000, + ) + + # Round-trip self-check; raises if our output isn't loadable. + yaml.safe_load(body) + + parts: list[str] = [] + if header: + parts.append(f"# {header}") + if fence: + parts.append("```yaml") + parts.append(body.rstrip()) + if fence: + parts.append("```") + return "\n".join(parts) + "\n" + + +# --- Validation ------------------------------------------------------------ + +def validate_blocks( + blocks: dict[str, list[BlockEntry]], + nodenorm: CachedNodeNorm, +) -> list[ValidationResult]: + """Run each (assertion, param_set) through the matching ASSERTION_HANDLER. + + Param_sets are evaluated one at a time so each ``TestResult`` can be + attributed back to its source CSV row. ``CachedNodeNorm`` deduplicates + network calls per CURIE, so the per-row loop is no slower than batching. + """ + # Pre-warm the cache with every CURIE we'll need across all blocks. + all_curies: set[str] = set() + for assertion, entries in blocks.items(): + handler = ASSERTION_HANDLERS[assertion.lower()] + for entry in entries: + all_curies.update(handler.curie_params(entry.param_set)) + if all_curies: + nodenorm.normalize_curies(list(all_curies)) + + results: list[ValidationResult] = [] + for assertion, entries in blocks.items(): + handler = ASSERTION_HANDLERS[assertion.lower()] + for entry in entries: + test_results = list( + handler.test_with_nodenorm( + [entry.param_set], nodenorm, + label=f"row {entry.row_idx}", + ) + ) + if test_results and all(r.status == TestStatus.Passed for r in test_results): + status = TestStatus.Passed + messages: list[str] = [] + else: + status = TestStatus.Failed + messages = [r.message for r in test_results + if r.status != TestStatus.Passed] or ["no result"] + results.append(ValidationResult( + assertion=assertion, + row_idx=entry.row_idx, + param_set=entry.param_set, + status=status, + messages=messages, + )) + return results + + +def format_report( + results: list[ValidationResult], + target_name: str, + nodenorm_url: str, +) -> str: + """Human-readable validation summary, suitable for stderr.""" + by_assertion: dict[str, list[ValidationResult]] = defaultdict(list) + for r in results: + by_assertion[r.assertion].append(r) + + lines = [f"Validation against target {target_name!r} ({nodenorm_url}):"] + for assertion, rows in by_assertion.items(): + passed = sum(1 for r in rows if r.status == TestStatus.Passed) + failed = sum(1 for r in rows if r.status == TestStatus.Failed) + lines.append(f" {assertion}: {passed} passed, {failed} failed.") + for r in rows: + if r.status == TestStatus.Failed: + params_str = ", ".join(r.param_set) + lines.append( + f" FAIL row {r.row_idx} [{params_str}] → {r.messages[0]}" + ) + return "\n".join(lines) + "\n" + + +# --- targets.ini ----------------------------------------------------------- + +def _default_targets_ini() -> Path: + """Locate tests/targets.ini relative to this file's repo.""" + # src/babel_validation/tools/csv_to_babeltests.py → ../../../tests/targets.ini + return Path(__file__).resolve().parents[3] / "tests" / "targets.ini" + + +def load_nodenorm_url(target_name: str, targets_ini_path: Path) -> str: + """Look up ``NodeNormURL`` for ``target_name`` in ``targets_ini_path``.""" + if not targets_ini_path.is_file(): + raise click.ClickException(f"targets.ini not found at {targets_ini_path}") + cp = configparser.ConfigParser() + cp.read(targets_ini_path, encoding="utf-8") + if target_name not in cp: + raise click.ClickException( + f"target {target_name!r} not found in {targets_ini_path}; " + f"available: {', '.join(cp.sections())}" + ) + section = cp[target_name] + if "NodeNormURL" not in section: + raise click.ClickException( + f"target {target_name!r} in {targets_ini_path} has no NodeNormURL" + ) + return section["NodeNormURL"] + + +# --- CLI ------------------------------------------------------------------- + +@click.command(context_settings={"help_option_names": ["-h", "--help"]}) +@click.argument( + "input_csv", + type=click.Path(exists=True, dir_okay=False, allow_dash=True, path_type=Path), +) +@click.option( + "--curie-column", required=True, + help="Column name containing the primary CURIE.", +) +@click.option( + "--label-column", default=None, + help="Column with the expected label → emits a HasLabel block.", +) +@click.option( + "--type-column", default=None, + help="Column with a Biolink type (e.g. 'biolink:SmallMolecule') → emits a ResolvesWithType block.", +) +@click.option( + "--equivalent-curie-column", default=None, + help="Column with a CURIE that should merge to the same canonical id → emits a ResolvesWith block.", +) +@click.option( + "--resolves/--no-resolves", "emit_resolves_flag", default=None, + help=("Force-emit (or suppress) a Resolves block. Default: emit a Resolves " + "block only when no other assertion column was given."), +) +@click.option("--dedupe", is_flag=True, default=False, + help="Drop duplicate param_sets within each assertion block.") +@click.option("--skip-empty/--no-skip-empty", default=True, + help="Skip rows where the assertion column is blank (with a stderr warning).") +@click.option("--delimiter", default=None, + help="CSV delimiter (default: auto-detect via csv.Sniffer; falls back to comma).") +@click.option("--target", default=None, + help="Target name from targets.ini to validate against (e.g. dev, prod, ci).") +@click.option( + "--targets-ini", + type=click.Path(exists=False, dir_okay=False, path_type=Path), + default=None, + help="Override path to targets.ini (default: tests/targets.ini in the repo).", +) +@click.option("--fence/--no-fence", default=True, + help="Wrap output in ```yaml … ``` Markdown fences (default: on).") +@click.option("--header", default=None, + help="Optional comment line emitted above the YAML block " + "(useful for recording provenance).") +def main( + input_csv: Path, + curie_column: str, + label_column: str | None, + type_column: str | None, + equivalent_curie_column: str | None, + emit_resolves_flag: bool | None, + dedupe: bool, + skip_empty: bool, + delimiter: str | None, + target: str | None, + targets_ini: Path | None, + fence: bool, + header: str | None, +) -> None: + """Convert INPUT_CSV into a BabelTests YAML block on stdout.""" + + # Default behavior for --resolves: emit Resolves only when the user + # didn't ask for any of the other assertion blocks. + if emit_resolves_flag is None: + emit_resolves = not (label_column or type_column or equivalent_curie_column) + else: + emit_resolves = emit_resolves_flag + + rows = read_csv(input_csv, delimiter=delimiter) + if rows and curie_column not in rows[0]: + raise click.ClickException( + f"--curie-column {curie_column!r} not found in CSV header. " + f"Available columns: {list(rows[0].keys())}" + ) + for col_name, col_label in [ + (label_column, "--label-column"), + (type_column, "--type-column"), + (equivalent_curie_column, "--equivalent-curie-column"), + ]: + if col_name and rows and col_name not in rows[0]: + raise click.ClickException( + f"{col_label} {col_name!r} not found in CSV header. " + f"Available columns: {list(rows[0].keys())}" + ) + + blocks, warnings = build_blocks( + rows, + curie_column=curie_column, + label_column=label_column, + type_column=type_column, + equivalent_curie_column=equivalent_curie_column, + emit_resolves=emit_resolves, + dedupe=dedupe, + skip_empty=skip_empty, + ) + + if not blocks: + raise click.ClickException( + "No assertions to emit — every row was skipped or no assertion " + "columns were given. Pass --label-column / --type-column / " + "--equivalent-curie-column, or use --resolves to force a Resolves " + "block on the CURIE column." + ) + + for w in warnings: + click.echo(w, err=True) + + yaml_text = emit_yaml(blocks, fence=fence, header=header) + sys.stdout.write(yaml_text) + + if target: + ini_path = targets_ini or _default_targets_ini() + nodenorm_url = load_nodenorm_url(target, ini_path) + nodenorm = CachedNodeNorm.from_url(nodenorm_url) + results = validate_blocks(blocks, nodenorm) + click.echo(format_report(results, target, nodenorm_url), err=True) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/tests/tools/test_csv_to_babeltests.py b/tests/tools/test_csv_to_babeltests.py new file mode 100644 index 0000000..32aad26 --- /dev/null +++ b/tests/tools/test_csv_to_babeltests.py @@ -0,0 +1,226 @@ +"""Unit tests for the csv-to-babeltests CLI helpers. + +These exercise the pure-function parts of the tool — CSV parsing, block +construction, YAML emission, and round-trip parsing through the same regex ++ yaml.safe_load that the GitHub-issue test discovery uses. No network. +""" + +import re +from pathlib import Path + +import pytest +import yaml + +from src.babel_validation.tools.csv_to_babeltests import ( + BlockEntry, + build_blocks, + emit_yaml, + read_csv, +) + + +pytestmark = pytest.mark.unit + + +# Same regex as src/babel_validation/sources/github/github_issues_test_cases.py +GITHUB_YAML_PATTERN = re.compile(r"```yaml\s+babel_tests:\s+.*?\s+```", re.DOTALL) + + +def _parse_emitted(yaml_text: str) -> dict: + """Run emit_yaml() output through the GitHub-issue parser path.""" + match = GITHUB_YAML_PATTERN.search(yaml_text) + assert match is not None, f"emitted YAML didn't match the issue regex:\n{yaml_text}" + return yaml.safe_load( + match.group(0).removeprefix("```yaml").removesuffix("```") + ) + + +# --- read_csv -------------------------------------------------------------- + +def test_read_csv_basic(tmp_path: Path): + p = tmp_path / "in.csv" + p.write_text("CURIE,Label\nCHEBI:15365,aspirin\nMONDO:1,asthma\n", encoding="utf-8") + rows = read_csv(p) + assert rows == [ + {"CURIE": "CHEBI:15365", "Label": "aspirin"}, + {"CURIE": "MONDO:1", "Label": "asthma"}, + ] + + +def test_read_csv_strips_bom(tmp_path: Path): + p = tmp_path / "in.csv" + p.write_bytes("CURIE,Label\nCHEBI:1,foo\n".encode("utf-8")) + rows = read_csv(p) + assert rows[0]["CURIE"] == "CHEBI:1" + + +def test_read_csv_handles_quoted_field_with_comma(tmp_path: Path): + p = tmp_path / "in.csv" + p.write_text( + 'CURIE,"Long, Name"\nCHEBI:1,"foo, bar"\n', encoding="utf-8" + ) + rows = read_csv(p) + assert rows[0] == {"CURIE": "CHEBI:1", "Long, Name": "foo, bar"} + + +# --- build_blocks ---------------------------------------------------------- + +ROWS = [ + {"OutputID": "CHEBI:15365", "Label": "aspirin", "Type": "biolink:SmallMolecule", "Equiv": "PUBCHEM.COMPOUND:1"}, + {"OutputID": "MONDO:0005015", "Label": "diabetes", "Type": "biolink:Disease", "Equiv": ""}, + {"OutputID": "", "Label": "missing", "Type": "", "Equiv": ""}, + {"OutputID": "CHEBI:15365", "Label": "aspirin", "Type": "biolink:SmallMolecule", "Equiv": "PUBCHEM.COMPOUND:1"}, # dup +] + + +def test_build_blocks_haslabel_only(): + blocks, warnings = build_blocks( + ROWS, + curie_column="OutputID", label_column="Label", + type_column=None, equivalent_curie_column=None, + emit_resolves=False, dedupe=False, skip_empty=True, + ) + assert set(blocks) == {"HasLabel"} + assert [e.param_set for e in blocks["HasLabel"]] == [ + ["CHEBI:15365", "aspirin"], + ["MONDO:0005015", "diabetes"], + ["CHEBI:15365", "aspirin"], # dup kept (dedupe=False) + ] + # row 4 (offset 2 → row index 4) was empty CURIE. + assert any("row 4" in w and "OutputID" in w for w in warnings) + + +def test_build_blocks_dedupe_drops_duplicate_param_sets(): + blocks, _ = build_blocks( + ROWS, + curie_column="OutputID", label_column="Label", + type_column=None, equivalent_curie_column=None, + emit_resolves=False, dedupe=True, skip_empty=True, + ) + assert [e.param_set for e in blocks["HasLabel"]] == [ + ["CHEBI:15365", "aspirin"], + ["MONDO:0005015", "diabetes"], + ] + + +def test_build_blocks_multiple_assertions(): + blocks, _ = build_blocks( + ROWS, + curie_column="OutputID", label_column="Label", + type_column="Type", equivalent_curie_column="Equiv", + emit_resolves=True, dedupe=True, skip_empty=True, + ) + # All four assertion blocks should be present. + assert set(blocks) == {"HasLabel", "ResolvesWithType", "ResolvesWith", "Resolves"} + # ResolvesWithType places the Biolink type first. + rwt = [e.param_set for e in blocks["ResolvesWithType"]] + assert rwt == [ + ["biolink:SmallMolecule", "CHEBI:15365"], + ["biolink:Disease", "MONDO:0005015"], + ] + # ResolvesWith only fires when Equiv is non-empty. + rw = [e.param_set for e in blocks["ResolvesWith"]] + assert rw == [["CHEBI:15365", "PUBCHEM.COMPOUND:1"]] + + +def test_build_blocks_row_indices_match_spreadsheet_rows(): + """row_idx should be 1-based with the header at row 1 — matches spreadsheet UIs.""" + blocks, _ = build_blocks( + ROWS, + curie_column="OutputID", label_column="Label", + type_column=None, equivalent_curie_column=None, + emit_resolves=False, dedupe=False, skip_empty=True, + ) + indices = [e.row_idx for e in blocks["HasLabel"]] + # ROWS[0] → row 2, ROWS[1] → row 3, ROWS[3] → row 5 (ROWS[2] was empty). + assert indices == [2, 3, 5] + + +# --- emit_yaml ------------------------------------------------------------- + +def test_emit_yaml_round_trips_through_github_parser(): + blocks = { + "HasLabel": [ + BlockEntry(2, ["CHEBI:15365", "aspirin"]), + BlockEntry(3, ["MONDO:0005015", "type 2 diabetes mellitus"]), + ], + "ResolvesWithType": [ + BlockEntry(2, ["biolink:SmallMolecule", "CHEBI:15365"]), + ], + } + out = emit_yaml(blocks, fence=True, header=None) + parsed = _parse_emitted(out) + assert list(parsed["babel_tests"].keys()) == ["HasLabel", "ResolvesWithType"] + assert parsed["babel_tests"]["HasLabel"] == [ + ["CHEBI:15365", "aspirin"], + ["MONDO:0005015", "type 2 diabetes mellitus"], + ] + assert parsed["babel_tests"]["ResolvesWithType"] == [ + ["biolink:SmallMolecule", "CHEBI:15365"], + ] + + +def test_emit_yaml_with_special_characters(): + """Labels with apostrophes, commas, brackets must round-trip.""" + blocks = { + "HasLabel": [ + BlockEntry(2, ["DRUGBANK:DB00001", "Adenosine 5'-phosphosulfate"]), + BlockEntry(3, ["CHEBI:1", "foo, bar [baz]"]), + BlockEntry(4, ["CHEBI:2", ""]), # empty label edge case + ], + } + out = emit_yaml(blocks) + parsed = _parse_emitted(out) + assert parsed["babel_tests"]["HasLabel"] == [ + ["DRUGBANK:DB00001", "Adenosine 5'-phosphosulfate"], + ["CHEBI:1", "foo, bar [baz]"], + ["CHEBI:2", ""], + ] + + +def test_emit_yaml_no_fence(): + blocks = {"Resolves": [BlockEntry(2, ["CHEBI:15365"])]} + out = emit_yaml(blocks, fence=False) + assert "```" not in out + assert "babel_tests:" in out + # Without the fence the GitHub parser regex shouldn't find a match. + assert GITHUB_YAML_PATTERN.search(out) is None + + +def test_emit_yaml_with_header_comment(): + blocks = {"Resolves": [BlockEntry(2, ["CHEBI:15365"])]} + out = emit_yaml(blocks, header="from data/test-assets.csv") + assert out.startswith("# from data/test-assets.csv\n") + # Header doesn't break the round-trip. + parsed = _parse_emitted(out) + # Single-element param_sets are emitted as bare strings (matching the + # convention in assertions/nodenorm.py); the GitHub parser later wraps + # them back into [["CHEBI:15365"]]. + assert parsed["babel_tests"]["Resolves"] == ["CHEBI:15365"] + + +def test_emit_yaml_single_element_param_sets_emit_as_bare_strings(): + """Single-element param_sets should match the existing YAML examples in + assertions/nodenorm.py — bare strings, not single-item flow lists.""" + blocks = {"Resolves": [BlockEntry(2, ["CHEBI:15365"]), BlockEntry(3, ["MONDO:1"])]} + out = emit_yaml(blocks, fence=False) + # Bare-string form, not single-element flow lists. + assert "- CHEBI:15365" in out + assert "- [CHEBI:15365]" not in out + + +# --- assertion-name compatibility with ASSERTION_HANDLERS ------------------ + +def test_emitted_assertion_names_match_handler_registry(): + """The names build_blocks emits must each resolve in ASSERTION_HANDLERS.""" + from src.babel_validation.assertions import ASSERTION_HANDLERS + blocks, _ = build_blocks( + ROWS, + curie_column="OutputID", label_column="Label", + type_column="Type", equivalent_curie_column="Equiv", + emit_resolves=True, dedupe=True, skip_empty=True, + ) + for assertion in blocks: + assert assertion.lower() in ASSERTION_HANDLERS, ( + f"build_blocks emitted unknown assertion name {assertion!r}" + ) diff --git a/uv.lock b/uv.lock index 18f74c6..b8b00ce 100644 --- a/uv.lock +++ b/uv.lock @@ -26,22 +26,26 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "black" }, + { name = "click" }, { name = "deepdiff" }, { name = "filelock" }, { name = "openapi-spec-validator" }, { name = "pytest" }, { name = "pytest-timeout" }, + { name = "pyyaml" }, { name = "requests" }, ] [package.metadata] requires-dist = [ { name = "black", specifier = ">=25.9.0" }, + { name = "click", specifier = ">=8.1" }, { name = "deepdiff", specifier = ">=8.6.1" }, { name = "filelock" }, { name = "openapi-spec-validator", specifier = ">=0.7.2" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "requests", specifier = ">=2.32.5" }, ]