From 11875664c6962d3cd01aa33f288b3461c3faf517 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 30 Oct 2026 16:41:24 -0500 Subject: [PATCH 1/2] Add an optional declaration to AnalyticFunctionSpec (ISSUE-170) An analytic function can now say what its results mean: result kind, measures with unit, as-of and basis, and the four absence sentences. It is the minimum of trellis-analytics' Declaration, with the same field names, so trellis declarations validate here (all 42 in Resource Explorer's analysis catalog do). Optional; existing entries unchanged. Claude-Session: https://claude.ai/code/session_015pdMuPkqttD1yKAKJLS9Fc Signed-off-by: Dan Wolfson --- PYEGERIA_ISSUES.md | 15 +++ pyegeria/view/analytic_registry.py | 101 +++++++++++++++++- .../test_issue_170_analytic_declaration.py | 75 +++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 tests/micro-tests/test_issue_170_analytic_declaration.py diff --git a/PYEGERIA_ISSUES.md b/PYEGERIA_ISSUES.md index 7d765d1d..7b9c7e34 100644 --- a/PYEGERIA_ISSUES.md +++ b/PYEGERIA_ISSUES.md @@ -172,6 +172,7 @@ re-test candidates moved into *Awaiting re-test* below the open sections. create-from-template) and 7 (`viewElementGUID`) are still open. **Open pyegeria items** +- ISSUE-170: `AnalyticFunctionSpec` can't say what its results mean (unit, as-of, basis, absence sentences); added an optional `declaration` - ISSUE-168: pyegeria can't run on Python 3.14: `nest_asyncio`, applied at import time, makes `asyncio.current_task()` return None - ISSUE-155: pyegeria gaps against Egeria `main` since 6.2 (omvs_audit + source diff, V6.2..05a5e982) - ISSUE-150: Dr.Egeria commands for an Analytic Function's Egeria declaration: a GovernanceActionType that Resource Explorer runs as an engine host, plus a synchronous path that bypasses Egeria @@ -193,6 +194,20 @@ into the entry now, so it isn't rediscovered from scratch later. --- +### ISSUE-170: `AnalyticFunctionSpec` can't say what its results mean (unit, as-of, basis, absence sentences); added an optional `declaration` + +**Layer:** pyegeria · **Status:** in PR (branch `feat/analytic-declaration`) · **Found:** 2026-10-30, Resource Explorer's portal-extensibility evaluation, §3 item 1 (trellis `docs/design-notes/EVAL-PORTAL-EXTENSIBILITY-VS-RE-2026-10-30.md`). + +**What:** trellis-analytics' `Declaration` lets a step say what its results are: result kind, each measure's unit, as-of and basis, and a sentence for each way a result can be absent. Resource Explorer renders from that declaration with no per-analysis code. pyegeria's registry, which the Portal also registers into, had no place for it, so a Portal function's meaning lived only in prose. + +**Change:** `AnalyticFunctionSpec.declaration: Optional[AnalyticDeclaration]`, plus `MeasureDeclaration`, `BasisDeclaration`, `AbsenceSentences`, `RESULT_KINDS` and `ABSENCE_STATES` in `pyegeria/view/analytic_registry.py`. It is the minimum of trellis's model: `result_kind`, `measures` (required for measure/series/distribution/graph) and all four `absence` sentences. Field names and vocabulary are the same as trellis's. Trellis's richer blocks (extents, confidence, answers, categories, ...) are ignored, not refused. Optional, so every existing entry loads unchanged. All 42 declarations in RE's `analysis_catalog.yaml` validate (checked 2026-10-30). trellis's `StepManifest` already overrides the field with its own `Declaration`, which still works. + +**Rule from the evaluation:** a field two consumers need lives here; a field one consumer needs stays in that consumer's subclass. Moving more of trellis's model up waits for a second consumer. + +Tests: `tests/micro-tests/test_issue_170_analytic_declaration.py`. + +--- + ### ISSUE-168: pyegeria can't run on Python 3.14: `nest_asyncio`, applied at import time, makes `asyncio.current_task()` return None **Layer:** pyegeria · **Status:** fix drafted on branch `fix/issue-168-py314-sync-runner` (not merged) · **Found:** 2026-10-29, when the owner asked why pyegeria caps Python at `<3.14`. diff --git a/pyegeria/view/analytic_registry.py b/pyegeria/view/analytic_registry.py index 7c8ebf5b..b7c21593 100644 --- a/pyegeria/view/analytic_registry.py +++ b/pyegeria/view/analytic_registry.py @@ -42,12 +42,18 @@ from typing import Any, Dict, List, Literal, Optional, Union from loguru import logger -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator __all__ = [ 'AnalyticParam', 'AnalyticFunctionSpec', 'AnalyticActionSpec', + 'AnalyticDeclaration', + 'MeasureDeclaration', + 'BasisDeclaration', + 'AbsenceSentences', + 'RESULT_KINDS', + 'ABSENCE_STATES', 'KNOWN_ANALYTIC_SOURCES', 'AnalyticRegistryCollision', 'refresh_analytic_functions', @@ -105,6 +111,96 @@ class AnalyticActionSpec(BaseModel): analytic_params: List[AnalyticParam] = Field(default_factory=list) +# Declaration (ISSUE-170): what an analytic function says about its own results, +# so a screen, report or chat answer can show them with no code written for that +# function. The minimum of trellis-analytics' `Declaration` (result kind, measures +# with unit/as-of/basis, the four absence sentences), with the same field names and +# vocabulary, so a trellis declaration validates here too; its richer blocks +# (extents, confidence, answers, ...) are ignored by this model, not refused. + +#: The fixed result-kind vocabulary (trellis `RESULT_KINDS`). +RESULT_KINDS = ("measure", "series", "distribution", "list", "graph", "finding", "proposal", "table") + +#: The four ways a result can be absent, each a sentence the function declares. Envelope states +#: map onto them: nothing_found -> measured_nothing; not_established -> not_measured (or +#: not_permitted, told apart by the reason); not_applicable -> not_applicable. +ABSENCE_STATES = ("not_measured", "not_permitted", "not_applicable", "measured_nothing") + + +def _words(value: Any) -> str: + return " ".join(str(value or "").split()) + + +class BasisDeclaration(BaseModel): + """The whole a counted measure is part of, so "456 of 1,236 columns" is derived, not written.""" + denominator_field: str # the result field holding the whole, e.g. "column_count" + denominator_label: str # the whole in words, plural, e.g. "columns" + + @model_validator(mode="after") + def _filled(self) -> "BasisDeclaration": + if not _words(self.denominator_field) or not _words(self.denominator_label): + raise ValueError("a basis needs both denominator_field and denominator_label") + return self + + +class MeasureDeclaration(BaseModel): + """One number a function reports. A number never travels without its unit and its as-of.""" + name: str + unit: str # in words, plural where it counts things: "tables", "fraction of rows (0-1)" + as_of: str # the result field saying when this was measured, e.g. "asOf" + value_field: str # the result field holding the value; dotted for nested, trailing [] counts a list + basis: Optional[BasisDeclaration] = None + capped_field: str = "" # the result field that is true when the value is only a lower bound + + @model_validator(mode="after") + def _filled(self) -> "MeasureDeclaration": + empty = [f for f in ("name", "unit", "as_of", "value_field") if not _words(getattr(self, f))] + if empty: + raise ValueError(f"measure '{self.name}' declares no {', '.join(empty)}: " + "a number never travels without its unit and its as-of") + return self + + +class AbsenceSentences(BaseModel): + """The sentence shown for each way a result can be absent. All four are declared.""" + not_measured: str + not_permitted: str + not_applicable: str + measured_nothing: str + + @model_validator(mode="after") + def _filled(self) -> "AbsenceSentences": + empty = [s for s in ABSENCE_STATES if not _words(getattr(self, s))] + if empty: + raise ValueError(f"absence sentence for {', '.join(empty)} is empty: " + "an absence is declared, never improvised") + return self + + def sentence(self, state: str) -> str: + return getattr(self, state) + + +class AnalyticDeclaration(BaseModel): + """What an analytic function declares about its results: the minimum is `result_kind`, + `measures` (required for measure/series/distribution/graph) and `absence`.""" + result_kind: str + measures: List[MeasureDeclaration] = Field(default_factory=list) + absence: AbsenceSentences + + @field_validator("result_kind") + @classmethod + def _kind(cls, value: str) -> str: + if value not in RESULT_KINDS: + raise ValueError(f"result_kind '{value}' is not one of {', '.join(RESULT_KINDS)}") + return value + + @model_validator(mode="after") + def _measured_kinds_declare_measures(self) -> "AnalyticDeclaration": + if self.result_kind in ("measure", "series", "distribution", "graph") and not self.measures: + raise ValueError(f"result_kind '{self.result_kind}' declares no measures") + return self + + class AnalyticFunctionSpec(BaseModel): """One entry in the analytic function registry. @@ -147,6 +243,9 @@ class AnalyticFunctionSpec(BaseModel): resource_types: List[str] = Field(default_factory=list) # e.g. ["database"] family: str = "" # same word and meaning as FormatSet.family; in Egeria a family is a Collection tags: List[str] = Field(default_factory=list) + # What the results mean (ISSUE-170): result kind, measures with unit/as-of/basis, absence + # sentences. Optional; trellis-analytics' StepManifest narrows it to its fuller Declaration. + declaration: Optional[AnalyticDeclaration] = None @field_validator("source") @classmethod diff --git a/tests/micro-tests/test_issue_170_analytic_declaration.py b/tests/micro-tests/test_issue_170_analytic_declaration.py new file mode 100644 index 00000000..29e7aad8 --- /dev/null +++ b/tests/micro-tests/test_issue_170_analytic_declaration.py @@ -0,0 +1,75 @@ +"""ISSUE-170: an optional declaration on AnalyticFunctionSpec -- result kind, measures +with unit/as-of/basis, and the four absence sentences, mirroring trellis-analytics.""" +import pytest +from pydantic import ValidationError + +from pyegeria.view.analytic_registry import ( + AnalyticDeclaration, + AnalyticFunctionSpec, + analytic_registry_payload, + clear_runtime_analytic_functions, + get_analytic_registry, + register_analytic_functions, +) + +ABSENCE = { + "not_measured": "Coverage was not measured: the catalog could not be read.", + "not_permitted": "Coverage was not measured: reading the catalog is not permitted.", + "not_applicable": "Coverage does not apply to this resource.", + "measured_nothing": "No columns were found to cover.", +} + +DECLARATION = { + "result_kind": "measure", + "measures": [{ + "name": "described_columns", "unit": "columns", "as_of": "asOf", "value_field": "described", + "basis": {"denominator_field": "column_count", "denominator_label": "columns"}, + }], + "absence": ABSENCE, +} + + +def test_existing_specs_load_without_a_declaration(): + assert all(spec.declaration is None for spec in get_analytic_registry().values()) + + +def test_declaration_round_trips_on_a_spec(): + spec = AnalyticFunctionSpec(name="coverage", function="pyegeria.view.overview_metrics.growth_series", + declaration=DECLARATION) + m = spec.declaration.measures[0] + assert (m.unit, m.as_of, m.basis.denominator_label) == ("columns", "asOf", "columns") + assert spec.declaration.absence.sentence("measured_nothing") == ABSENCE["measured_nothing"] + assert AnalyticFunctionSpec(**spec.model_dump()).declaration == spec.declaration + + +def test_registry_payload_carries_the_declaration(): + clear_runtime_analytic_functions() + try: + register_analytic_functions({"test-170": {"name": "test-170", "declaration": DECLARATION, + "function": "pyegeria.view.overview_metrics.growth_series"}}) + payload = analytic_registry_payload()["functions"]["test-170"] + assert payload["declaration"]["absence"]["not_measured"] == ABSENCE["not_measured"] + finally: + clear_runtime_analytic_functions() + + +def test_richer_trellis_blocks_are_accepted_and_ignored(): + rich = dict(DECLARATION, extents=[{"name": "x"}], confidence={"form": "measured"}, answers=["Q-0001"]) + assert AnalyticDeclaration.model_validate(rich).result_kind == "measure" + + +@pytest.mark.parametrize("bad, words", [ + (dict(DECLARATION, result_kind="chart"), "not one of"), + (dict(DECLARATION, measures=[]), "declares no measures"), + (dict(DECLARATION, absence=dict(ABSENCE, not_permitted=" ")), "never improvised"), + (dict(DECLARATION, measures=[{"name": "m", "unit": "", "as_of": "asOf", "value_field": "v"}]), "unit"), + (dict(DECLARATION, measures=[dict(DECLARATION["measures"][0], + basis={"denominator_field": "n", "denominator_label": ""})]), "basis"), +]) +def test_incomplete_declarations_are_refused_with_a_sentence(bad, words): + with pytest.raises(ValidationError, match=words): + AnalyticDeclaration.model_validate(bad) + + +def test_unmeasured_kinds_need_no_measures(): + assert AnalyticDeclaration(result_kind="finding", absence=ABSENCE).measures == [] From 9e48646736a8d1b5e97c8019310e61032997dbf7 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Fri, 30 Oct 2026 16:44:52 -0500 Subject: [PATCH 2/2] Say why an analytic result is absent instead of returning it as data (ISSUE-171) A not_established or not_applicable result now comes back from exec_report_spec as kind "absent" with the function's declared absence sentence (ISSUE-170), on every output format, with no top-level data; a partial result rides under "detail". nothing_found keeps its data or chart and carries the measured_nothing sentence. A function with no declaration gets words built from its own reason, marked declared=False. not_permitted is used only when the result claims it. Claude-Session: https://claude.ai/code/session_015pdMuPkqttD1yKAKJLS9Fc Signed-off-by: Dan Wolfson --- PYEGERIA_ISSUES.md | 20 +++ pyegeria/view/format_set_executor.py | 89 ++++++++++-- ...est_issue_138_140_149_analytic_envelope.py | 6 +- .../test_issue_171_state_aware_report_spec.py | 132 ++++++++++++++++++ 4 files changed, 233 insertions(+), 14 deletions(-) create mode 100644 tests/micro-tests/test_issue_171_state_aware_report_spec.py diff --git a/PYEGERIA_ISSUES.md b/PYEGERIA_ISSUES.md index 7b9c7e34..89838c07 100644 --- a/PYEGERIA_ISSUES.md +++ b/PYEGERIA_ISSUES.md @@ -172,6 +172,7 @@ re-test candidates moved into *Awaiting re-test* below the open sections. create-from-template) and 7 (`viewElementGUID`) are still open. **Open pyegeria items** +- ISSUE-171: `exec_report_spec` returned an analytic result that was not measured as ordinary data or `empty`; it now returns the function's declared absence sentence - ISSUE-170: `AnalyticFunctionSpec` can't say what its results mean (unit, as-of, basis, absence sentences); added an optional `declaration` - ISSUE-168: pyegeria can't run on Python 3.14: `nest_asyncio`, applied at import time, makes `asyncio.current_task()` return None - ISSUE-155: pyegeria gaps against Egeria `main` since 6.2 (omvs_audit + source diff, V6.2..05a5e982) @@ -208,6 +209,25 @@ Tests: `tests/micro-tests/test_issue_170_analytic_declaration.py`. --- +### ISSUE-171: `exec_report_spec` returned an analytic result that was not measured as ordinary data or `empty`; it now returns the function's declared absence sentence + +**Layer:** pyegeria · **Status:** in PR (branch `feat/state-aware-report-spec`, stacked on ISSUE-170's `feat/analytic-declaration`) · **Found:** 2026-10-30, Resource Explorer's portal-extensibility evaluation, §3 item 2. + +**What:** ISSUE-139/140 stopped charting a `not_established`/`not_applicable` result. The DICT path and the chart fallback still returned `{"kind": "json", "data": }`, though, so a table or tile showed the routine's placeholder zeros as an answer. And `{"kind": "empty"}` looks the same to a reader whether nothing was found or nobody looked. Egeria Advisor, for example, renders `empty` as "No results returned". + +**Change** (`pyegeria/view/format_set_executor.py`): +- **not_established / not_applicable** results now return `{"kind": "absent", "content": , "absence": {...}, "envelope": {...}}` on every output format. There is no top-level `data`; a partial result rides under `detail`. +- **nothing_found** results keep their data or chart (real zeros) and gain the same `absence` block. A chart's subtitle is the declared `measured_nothing` sentence. +- **measured** results, and results with no envelope, are unchanged. + +`absence` gives the state (`not_measured`, `not_permitted`, `not_applicable` or `measured_nothing`), the sentence, the reason, and `declared`. The sentence comes from the function's registered `declaration.absence` (ISSUE-170), looked up by registry name or by dotted path. A function with no declaration gets words built from its own reason, with `declared: False`. `not_established` becomes `not_permitted` only when the result says so itself (`"absence": "not_permitted"`); the reason text is never parsed to guess. + +**Consumers to update:** Egeria Advisor's `report_pipeline`/`report_spec_agent` (trellis) treat an unknown kind generically. `content` is among the keys its fallback reads, so it shows the sentence, but it should handle `absent` explicitly. Any Portal tile that reads `data` for these states gets no `data` now. + +Tests: `tests/micro-tests/test_issue_171_state_aware_report_spec.py`; three ISSUE-140 tests updated to the new shape. + +--- + ### ISSUE-168: pyegeria can't run on Python 3.14: `nest_asyncio`, applied at import time, makes `asyncio.current_task()` return None **Layer:** pyegeria · **Status:** fix drafted on branch `fix/issue-168-py314-sync-runner` (not merged) · **Found:** 2026-10-29, when the owner asked why pyegeria caps Python at `<3.14`. diff --git a/pyegeria/view/format_set_executor.py b/pyegeria/view/format_set_executor.py index 76e7e87a..cb0a33b2 100644 --- a/pyegeria/view/format_set_executor.py +++ b/pyegeria/view/format_set_executor.py @@ -33,7 +33,8 @@ get_report_registry, ) from pyegeria.view.output_formatter import generate_output -from pyegeria.view.analytic_registry import AnalyticActionSpec +from pyegeria.view.analytic_registry import ABSENCE_STATES, AnalyticActionSpec +from pyegeria.view.analytic_registry import get_analytic_registry as get_analytic_registry_functions from pyegeria.egeria_tech_client import EgeriaTech _CLIENT_CLASS_MAP = { @@ -217,7 +218,10 @@ def _from_mapping(m: Dict[str, Any]) -> Optional[Dict[str, str]]: return None reason = str(m.get("state_reason", m.get("resultStateReason", m.get("result_state_reason", ""))) or "") subtitle = m.get("subtitle") or (state.replace("_", " ") + (f" \u00b7 {reason}" if reason else "")) - return {"state": state, "reason": reason, "subtitle": str(subtitle)} + found = {"state": state, "reason": reason, "subtitle": str(subtitle)} + if m.get("absence") in ABSENCE_STATES: # the result says which absence (ISSUE-171) + found["absence"] = m["absence"] + return found if isinstance(result, dict): found = _from_mapping(result) @@ -248,6 +252,62 @@ def _with_envelope(out: Dict[str, Any], envelope: Optional[Dict[str, str]]) -> D return out +# Envelope state -> the declared absence it is (ISSUE-171). not_established reads as +# not_measured unless the result itself claims not_permitted (an `absence` key); +# the reason text is never parsed to guess which. +_ABSENCE_FOR_STATE = { + "nothing_found": "measured_nothing", + "not_established": "not_measured", + "not_applicable": "not_applicable", +} + +# Used only when the function declares no absence sentences; built from its own reason. +_UNDECLARED_ABSENCE_WORDS = { + "not_measured": "Not measured", + "not_permitted": "Not measured: not permitted", + "not_applicable": "Does not apply", + "measured_nothing": "Measured; nothing found", +} + + +def _declaration_for(func_decl: str): + """The registered declaration for an analytic_function, by registry name or by the + dotted path a registered entry declares; None when the function declares none.""" + registry = get_analytic_registry_functions() + spec = registry.get(func_decl) or next((s for s in registry.values() if s.function == func_decl), None) + return spec.declaration if spec is not None else None + + +def _absence_for(envelope: Optional[Dict[str, str]], func_decl: str) -> Optional[Dict[str, Any]]: + """What a reader is told when a result is not a measured value (ISSUE-171): the absence + state, the function's declared sentence for it (or words built from its reason when it + declares none, marked declared=False), and the reason. None for a measured result or + one with no envelope, which keep their old shapes.""" + if not envelope or envelope["state"] not in _ABSENCE_FOR_STATE: + return None + state = _ABSENCE_FOR_STATE[envelope["state"]] + if state == "not_measured" and envelope.get("absence") == "not_permitted": + state = "not_permitted" + reason = envelope.get("reason", "") + declaration = _declaration_for(func_decl) + if declaration is not None: + return {"state": state, "sentence": declaration.absence.sentence(state), "reason": reason, + "declared": True} + words = _UNDECLARED_ABSENCE_WORDS[state] + return {"state": state, "sentence": f"{words}: {reason}." if reason else f"{words}.", "reason": reason, + "declared": False} + + +def _absent_output(result: Any, envelope: Dict[str, str], absence: Dict[str, Any]) -> Dict[str, Any]: + """Output for a result whose value is not established or does not apply: the sentence, + never top-level data a chart or table would draw as an answer. Any partial result rides + under `detail`.""" + out: Dict[str, Any] = {"kind": "absent", "content": absence["sentence"], "absence": absence} + if result not in (None, [], {}): + out["detail"] = result + return _with_envelope(out, envelope) + + def _run_analytic_function( action: dict, *, params: Dict[str, Any], view_server: str, view_url: str, user: str, user_pass: str, @@ -747,13 +807,14 @@ def _exec_analytic_chart( ) envelope = _result_envelope(result) result = _jsonable(result) - if not result: - return _with_envelope({"kind": "empty"}, envelope) + absence = _absence_for(envelope, action["analytic_function"]) # Chart only an established answer (ISSUE-140): measured, or nothing_found (a real - # zero). For not_established / not_applicable the numbers aren't known, so return - # the data and the state, never a chart whose zeros would read as an answer. + # zero). For not_established / not_applicable the numbers aren't known, so say so + # (ISSUE-171), never a chart whose zeros would read as an answer. if envelope and envelope["state"] not in CHARTABLE_STATES: - return _with_envelope({"kind": "json", "data": result}, envelope) + return _absent_output(result, envelope, absence) + if not result: + return _with_envelope({"kind": "empty", **({"absence": absence} if absence else {})}, envelope) try: heading = get_report_spec_heading(format_set_name) or str(format_set_name) @@ -764,8 +825,10 @@ def _chart_out(chart): if not chart: return _with_envelope({"kind": "json", "data": result}, envelope) if envelope: # the state rides on the chart itself (ISSUE-139) - chart["title"] = {"text": chart.get("title") or heading, "subtitle": envelope["subtitle"]} - return _with_envelope({"kind": "json", "data": chart}, envelope) + subtitle = absence["sentence"] if absence and absence["declared"] else envelope["subtitle"] + chart["title"] = {"text": chart.get("title") or heading, "subtitle": subtitle} + out = {"kind": "json", "data": chart, **({"absence": absence} if absence else {})} + return _with_envelope(out, envelope) if chart_kind == "line": if not isinstance(result, list) or not all(isinstance(pt, dict) for pt in result): @@ -943,9 +1006,13 @@ def exec_report_spec( ) envelope = _result_envelope(result) result = _jsonable(result) + absence = _absence_for(envelope, action["analytic_function"]) + if envelope and envelope["state"] not in CHARTABLE_STATES: + return _absent_output(result, envelope, absence) # ISSUE-171 + extra = {"absence": absence} if absence else {} if result is None or result == [] or result == {}: - return _with_envelope({"kind": "empty"}, envelope) - return _with_envelope({"kind": "json", "data": result}, envelope) + return _with_envelope({"kind": "empty", **extra}, envelope) + return _with_envelope({"kind": "json", "data": result, **extra}, envelope) required_params = action.get("required_params", action.get("user_params", [])) or [] optional_params = action.get("optional_params", []) or [] diff --git a/tests/micro-tests/test_issue_138_140_149_analytic_envelope.py b/tests/micro-tests/test_issue_138_140_149_analytic_envelope.py index af98a758..de156449 100644 --- a/tests/micro-tests/test_issue_138_140_149_analytic_envelope.py +++ b/tests/micro-tests/test_issue_138_140_149_analytic_envelope.py @@ -117,7 +117,7 @@ def test_measured_bar_gets_subtitle_and_envelope(monkeypatch): def test_not_established_draws_no_chart(monkeypatch): _use_spec(monkeypatch, {"analytic_function": f"{MOD}.not_established"}) out = fse.exec_report_spec("tile", output_format="BAR") - assert "$schema" not in out["data"] + assert out["kind"] == "absent" and "data" not in out # ISSUE-171: the sentence, not a chart assert out["envelope"] == {"state": "not_established", "reason": "could not open the ODS", "subtitle": "not established · could not open the ODS"} @@ -132,13 +132,13 @@ def test_nothing_found_is_charted_as_real_zeros(monkeypatch): def test_not_applicable_draws_no_chart(monkeypatch): _use_spec(monkeypatch, {"analytic_function": f"{MOD}.not_applicable"}) out = fse.exec_report_spec("tile", output_format="BAR") - assert "$schema" not in out["data"] and out["envelope"]["state"] == "not_applicable" + assert out["kind"] == "absent" and out["envelope"]["state"] == "not_applicable" def test_dict_output_carries_envelope(monkeypatch): _use_spec(monkeypatch, {"analytic_function": f"{MOD}.not_established"}) out = fse.exec_report_spec("tile", output_format="DICT") - assert out["kind"] == "json" and out["envelope"]["state"] == "not_established" + assert out["kind"] == "absent" and out["envelope"]["state"] == "not_established" # ISSUE-171 def test_model_result_with_envelope_attribute(monkeypatch): diff --git a/tests/micro-tests/test_issue_171_state_aware_report_spec.py b/tests/micro-tests/test_issue_171_state_aware_report_spec.py new file mode 100644 index 00000000..a14bb5a8 --- /dev/null +++ b/tests/micro-tests/test_issue_171_state_aware_report_spec.py @@ -0,0 +1,132 @@ +"""ISSUE-171: when an analytic function's result is not a measured value, exec_report_spec +says so -- with the function's declared absence sentence -- instead of returning empty +or drawing numbers that read as an answer.""" +import sys +import types + +import pytest + +import pyegeria.view.format_set_executor as fse +from pyegeria.view.analytic_registry import clear_runtime_analytic_functions, register_analytic_functions + +MOD = "fake_issue_171_analytics" + +ABSENCE = { + "not_measured": "Coverage was not measured: the catalog could not be read.", + "not_permitted": "Coverage was not measured: reading the catalog is not permitted.", + "not_applicable": "Coverage does not apply to this resource.", + "measured_nothing": "The catalog was read and holds no columns.", +} + + +def not_established(): + return {"state": "not_established", "state_reason": "could not open the ODS", "described": 0} + + +def not_permitted(): + return {"state": "not_established", "state_reason": "403 from the catalog", "absence": "not_permitted"} + + +def not_applicable(): + return {"state": "not_applicable", "state_reason": "not a database"} + + +def nothing_found_empty(): + return {"envelope": {"state": "nothing_found"}, "counts": {}} + + +def nothing_found_counts(): + return {"state": "nothing_found", "byType": {"Table": 0, "Column": 0}} + + +def measured(): + return {"state": "measured", "byType": {"Table": 3, "Column": 12}} + + +@pytest.fixture(autouse=True) +def fake_module(monkeypatch): + mod = types.ModuleType(MOD) + for f in (not_established, not_permitted, not_applicable, nothing_found_empty, nothing_found_counts, measured): + setattr(mod, f.__name__, f) + monkeypatch.setitem(sys.modules, MOD, mod) + monkeypatch.setenv("PYEGERIA_ANALYTIC_MODULE_PREFIXES", f"pyegeria,{MOD}") # if ISSUE-169's resolver is in + monkeypatch.setattr(fse, "EgeriaTech", lambda *a, **k: pytest.fail("no Egeria call expected")) + monkeypatch.setattr(fse, "get_report_spec_heading", lambda name: "Coverage") + clear_runtime_analytic_functions() + yield + clear_runtime_analytic_functions() + + +def _declare(func_name: str): + register_analytic_functions({f"cov-{func_name}": { + "name": f"cov-{func_name}", "function": f"{MOD}.{func_name}", + "declaration": {"result_kind": "measure", "absence": ABSENCE, "measures": [ + {"name": "described", "unit": "columns", "as_of": "asOf", "value_field": "described"}]}, + }}) + + +def _run(monkeypatch, func_name: str, output_format: str = "DICT"): + fmt = {"action": {"analytic_function": f"{MOD}.{func_name}"}, "target_type": "Referenceable"} + monkeypatch.setattr(fse, "select_report_spec", lambda name, fmt_type: fmt) + return fse.exec_report_spec("coverage", output_format=output_format) + + +@pytest.mark.parametrize("output_format", ["DICT", "BAR"]) +def test_not_established_says_the_declared_sentence_and_draws_nothing(monkeypatch, output_format): + _declare("not_established") + out = _run(monkeypatch, "not_established", output_format) + assert out["kind"] == "absent" + assert out["content"] == ABSENCE["not_measured"] + assert out["absence"] == {"state": "not_measured", "sentence": ABSENCE["not_measured"], + "reason": "could not open the ODS", "declared": True} + assert "data" not in out # no top-level counts to draw as an answer + assert out["detail"]["described"] == 0 # the partial result is still there, labelled as detail + + +def test_the_result_can_claim_not_permitted(monkeypatch): + _declare("not_permitted") + out = _run(monkeypatch, "not_permitted") + assert (out["kind"], out["absence"]["state"], out["content"]) == ( + "absent", "not_permitted", ABSENCE["not_permitted"]) + + +def test_not_applicable_says_so(monkeypatch): + _declare("not_applicable") + out = _run(monkeypatch, "not_applicable", "BAR") + assert (out["kind"], out["content"]) == ("absent", ABSENCE["not_applicable"]) + + +def test_nothing_found_carries_the_measured_nothing_sentence(monkeypatch): + _declare("nothing_found_empty") + out = _run(monkeypatch, "nothing_found_empty") + assert out["kind"] == "json" # a real answer: the dict, with its sentence + assert out["absence"]["state"] == "measured_nothing" + assert out["absence"]["sentence"] == ABSENCE["measured_nothing"] + + +def test_nothing_found_counts_are_charted_with_the_declared_sentence(monkeypatch): + _declare("nothing_found_counts") + out = _run(monkeypatch, "nothing_found_counts", "BAR") + assert "$schema" in out["data"] + assert out["data"]["title"] == {"text": "Coverage", "subtitle": ABSENCE["measured_nothing"]} + + +def test_undeclared_function_gets_words_from_its_own_reason(monkeypatch): + out = _run(monkeypatch, "not_established") + assert out["kind"] == "absent" + assert out["content"] == "Not measured: could not open the ODS." + assert out["absence"]["declared"] is False + + +def test_measured_result_is_unchanged(monkeypatch): + _declare("measured") + out = _run(monkeypatch, "measured") + assert out["kind"] == "json" and "absence" not in out + assert out["data"]["byType"] == {"Table": 3, "Column": 12} + + +def test_registered_name_finds_the_declaration(monkeypatch): + _declare("not_established") + assert fse._declaration_for("cov-not_established").absence.not_measured == ABSENCE["not_measured"] + assert fse._declaration_for(f"{MOD}.not_established") is not None + assert fse._declaration_for(f"{MOD}.unregistered") is None