Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- `noirdoc ns summary <ns>` — 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.
Expand Down
18 changes: 18 additions & 0 deletions src/noirdoc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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?")
Expand Down
11 changes: 11 additions & 0 deletions src/noirdoc/pseudonymization/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
48 changes: 48 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions tests/test_mapper.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import json

from noirdoc.pseudonymization.mapper import PseudonymMapper


Expand Down Expand Up @@ -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")
Expand Down
Loading