diff --git a/CHANGELOG.md b/CHANGELOG.md index 8228862..3edda6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- `noirdoc ns summary ` — counts-only namespace inspection + (`total_entities`, per-label `by_type`). Safe to capture in wrapper + transcripts and audit logs; original values never appear in the output. + Companion to `ns show`. ([#1]) + +[#1]: https://github.com/nextaim-de/noirdoc/issues/1 + ## [0.1.0] — 2026-04-24 First public alpha on PyPI. diff --git a/src/noirdoc/cli.py b/src/noirdoc/cli.py index b0a38ec..1d14436 100644 --- a/src/noirdoc/cli.py +++ b/src/noirdoc/cli.py @@ -174,6 +174,24 @@ def ns_show(namespace: str) -> None: click.echo(json.dumps(mapper.get_mapping_summary(), indent=2, ensure_ascii=False)) +@ns.command("summary") +@click.argument("namespace") +def ns_summary(namespace: str) -> None: + """Print counts-only summary for NAMESPACE as JSON. + + Safe alternative to ``ns show`` when stdout may be captured by a + wrapper, transcript, or log pipeline — original values never appear + in the output. + """ + ns_obj = Namespace(namespace) + if not ns_obj.exists(): + click.echo(f"Namespace {namespace!r} does not exist.", err=True) + sys.exit(1) + mapper = ns_obj.load() + summary = {"namespace": namespace, **mapper.get_counts_summary()} + click.echo(json.dumps(summary, indent=2, ensure_ascii=False)) + + @ns.command("delete") @click.argument("namespace") @click.confirmation_option(prompt="Delete namespace and discard all mappings?") diff --git a/src/noirdoc/pseudonymization/mapper.py b/src/noirdoc/pseudonymization/mapper.py index 9289dd1..622d6c4 100644 --- a/src/noirdoc/pseudonymization/mapper.py +++ b/src/noirdoc/pseudonymization/mapper.py @@ -50,6 +50,17 @@ def get_mapping_summary(self) -> dict[str, str]: """Pseudonym -> Original Mapping (für Debug/Audit).""" return dict(self._pseudo_to_entity) + def get_counts_summary(self) -> dict[str, object]: + """Counts-only summary: total entities and per-label breakdown. + + Safe to log or emit to caller transcripts — original values never + enter the output. + """ + return { + "total_entities": self.entity_count, + "by_type": dict(self._counters), + } + @property def entity_count(self) -> int: return len(self._pseudo_to_entity) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..df0beb5 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +from noirdoc import cli as cli_module +from noirdoc import namespace as ns_module +from noirdoc.cli import main +from noirdoc.namespace import Namespace + + +def _redirect_namespace_root(monkeypatch, tmp_path: Path) -> Path: + root = tmp_path / "namespaces" + monkeypatch.setattr(ns_module, "DEFAULT_NAMESPACE_ROOT", root) + monkeypatch.setattr(cli_module, "DEFAULT_NAMESPACE_ROOT", root) + return root + + +def test_ns_summary_happy_path(monkeypatch, tmp_path: Path): + _redirect_namespace_root(monkeypatch, tmp_path) + + ns = Namespace("demo") + mapper = ns.load() + mapper.get_or_create("Max Müller", "PERSON") + mapper.get_or_create("Lisa Schmidt", "PERSON") + mapper.get_or_create("max@test.de", "EMAIL") + ns.save(mapper) + + result = CliRunner().invoke(main, ["ns", "summary", "demo"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload == { + "namespace": "demo", + "total_entities": 3, + "by_type": {"PERSON": 2, "EMAIL": 1}, + } + assert "Max Müller" not in result.output + assert "max@test.de" not in result.output + + +def test_ns_summary_missing_namespace(monkeypatch, tmp_path: Path): + _redirect_namespace_root(monkeypatch, tmp_path) + + result = CliRunner().invoke(main, ["ns", "summary", "nope"]) + assert result.exit_code == 1 + assert "Namespace 'nope' does not exist." in result.output diff --git a/tests/test_mapper.py b/tests/test_mapper.py index a2753bd..a0e7a84 100644 --- a/tests/test_mapper.py +++ b/tests/test_mapper.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + from noirdoc.pseudonymization.mapper import PseudonymMapper @@ -48,6 +50,20 @@ def test_mapping_summary(): } +def test_counts_summary(): + mapper = PseudonymMapper() + mapper.get_or_create("Max Müller", "PERSON") + mapper.get_or_create("Lisa Schmidt", "PERSON") + mapper.get_or_create("Max Müller", "PERSON") # dedup, no recount + mapper.get_or_create("max@test.de", "EMAIL") + summary = mapper.get_counts_summary() + assert summary == { + "total_entities": 3, + "by_type": {"PERSON": 2, "EMAIL": 1}, + } + assert "Max Müller" not in json.dumps(summary) + + def test_get_all_pseudonyms(): mapper = PseudonymMapper() mapper.get_or_create("Max Müller", "PERSON")