From a11fe9723787713b570d989ac2438bd6d967ad02 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 31 Oct 2026 07:43:01 -0500 Subject: [PATCH 1/3] Report specs name analytic functions by registered name; dotted paths deprecated (ISSUE-173) A dotted analytic_function, or metric_trend's metric_path, still resolves under the ISSUE-169 checks but now raises a DeprecationWarning and logs once, naming the registered name to use. metric_path previously imported any caller-supplied dotted path with no allowlist check; it now goes through the same resolver. pyegeria's own specs (the SoA summary and the analytic demos) now use registered names. Signed-off-by: Dan Wolfson --- pyegeria/view/analytic_demo_specs.py | 4 +- pyegeria/view/analytic_registry.py | 4 +- pyegeria/view/base_report_formats.py | 2 +- pyegeria/view/format_set_executor.py | 32 ++++++- pyegeria/view/overview_metrics.py | 19 ++-- .../test_issue_173_registered_names.py | 91 +++++++++++++++++++ 6 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 tests/micro-tests/test_issue_173_registered_names.py diff --git a/pyegeria/view/analytic_demo_specs.py b/pyegeria/view/analytic_demo_specs.py index 99d13cb4..f339ecea 100644 --- a/pyegeria/view/analytic_demo_specs.py +++ b/pyegeria/view/analytic_demo_specs.py @@ -75,7 +75,7 @@ def _demo( # never matches the data. formats=[Format(types=types, attributes=[])], action=ActionParameter( - analytic_function=spec.function, analytic_spec_params=demo_params, + analytic_function=analytic_function_name, analytic_spec_params=demo_params, ), ) @@ -324,7 +324,7 @@ def _demo( "any other function in this module (e.g. people_counts, feedback_summary) the same " "way. Run with output_format SERIES for a Vega-Lite line chart of governedCount.", analytic_function_name="metric_trend", - demo_params={"metric_path": "pyegeria.view.overview_metrics.governed_coverage", "window": "6mo"}, + demo_params={"metric_path": "governed_coverage", "window": "6mo"}, result_shape="list[dict] (time series: {label, date, **snapshot})", chart_types=["SERIES"], ), diff --git a/pyegeria/view/analytic_registry.py b/pyegeria/view/analytic_registry.py index 5836ff47..619e5d23 100644 --- a/pyegeria/view/analytic_registry.py +++ b/pyegeria/view/analytic_registry.py @@ -597,8 +597,8 @@ def _strict() -> AnalyticParam: generic=True, params=[ AnalyticParam(name="metric_path", type="str", required=True, - description="Dotted import path of the target function, e.g. " - "'pyegeria.view.overview_metrics.governed_coverage'."), + description="Registered name of the target analytic function, e.g. " + "'governed_coverage'. A dotted path is deprecated."), AnalyticParam(name="window", type="str", default="6mo", description="8h|1d|3d|7d|30d|90d|6mo|1y -- sets span + default point count."), AnalyticParam(name="points", type="int", diff --git a/pyegeria/view/base_report_formats.py b/pyegeria/view/base_report_formats.py index ce5eba3f..e9669c4c 100644 --- a/pyegeria/view/base_report_formats.py +++ b/pyegeria/view/base_report_formats.py @@ -2401,7 +2401,7 @@ def _user_report_specs_dir() -> str: family="Security Officer", formats=[Format(types=["DICT", "JSON"], attributes=[])], action=ActionParameter( - analytic_function="pyegeria.view.overview_metrics.control_implementation_summary", + analytic_function="control_implementation_summary", required_params=["soa_guid"], ), ), diff --git a/pyegeria/view/format_set_executor.py b/pyegeria/view/format_set_executor.py index 59e6167f..1e384274 100644 --- a/pyegeria/view/format_set_executor.py +++ b/pyegeria/view/format_set_executor.py @@ -18,6 +18,7 @@ import json import os import sys +import warnings from typing import Any, Dict, Optional, Union from loguru import logger @@ -160,6 +161,35 @@ class to resolve here, just the function itself. return _import_callable(func_decl) +_DOTTED_PATH_WARNED: set = set() + + +def resolve_report_spec_function(func_decl: str): + """Resolve a function a report spec names: its `analytic_function`, or a + function-valued parameter such as metric_trend's `metric_path`. + + Report specs name analytic functions by registered name (ISSUE-173). A + dotted path still resolves, under the same checks as + `_resolve_analytic_function`, but warns with a DeprecationWarning and one log + line per path, naming the registered name to use. Dotted paths are refused + from the next release. + """ + func = _resolve_analytic_function(func_decl) # a refused path raises before any warning + registry = get_analytic_registry_functions() + if func_decl in registry: + return func + names = sorted(n for n, s in registry.items() if s.function == func_decl) + instead = (f"use the registered name '{names[0]}'" if names else + "register it through the 'pyegeria.analytics' entry-point group and use its name") + message = (f"Report spec names analytic function '{func_decl}' by dotted path; {instead}. " + f"Dotted paths in report specs are deprecated and will be refused in the next release.") + warnings.warn(message, DeprecationWarning, stacklevel=2) + if func_decl not in _DOTTED_PATH_WARNED: + _DOTTED_PATH_WARNED.add(func_decl) + logger.warning(message) + return func + + # Parameter names an analytic function uses for its leading client argument(s) # -- pyegeria.view.overview_metrics's functions take one (`mgr` or `ce`) or, # for semantic_grounding, two (`mgr`, `ce`) positional clients before their @@ -401,7 +431,7 @@ def _run_analytic_function( call_params: Dict[str, Any] = dict(spec_params) call_params.update({k: v for k, v in params.items() if v not in (None, "")}) - func = _resolve_analytic_function(func_decl) + func = resolve_report_spec_function(func_decl) call_params = _apply_analytic_binders(func, action.get("analytic_binders") or {}, call_params) n_clients = _count_client_params(func) if n_clients == 0: diff --git a/pyegeria/view/overview_metrics.py b/pyegeria/view/overview_metrics.py index 5c17e6ed..802bf053 100644 --- a/pyegeria/view/overview_metrics.py +++ b/pyegeria/view/overview_metrics.py @@ -1479,12 +1479,11 @@ def metric_trend( (and future) fixed-metric function trend support for free, without adding `window`/`points` params to each one individually. - `metric_path` is a dotted import path exactly like `analytic_function`/ - `extra_find` declarations use (e.g. "pyegeria.view.overview_metrics. - governed_coverage") -- resolved via `importlib`, not the analytic - registry, to keep this module free of the report-spec-registry layer - (see module docstring's design boundary; the registry itself already - carries these same dotted paths in `AnalyticFunctionSpec.function`). + `metric_path` names the function the way a report spec's + `analytic_function` does: a registered analytic function name (e.g. + "governed_coverage"). It is resolved through the report-spec resolver + (ISSUE-173), so a caller-supplied value gets the same allowlist check; + a dotted path still works but is deprecated. Pass both `mgr` and `ce` regardless of what the target function actually needs -- only the client parameter names it declares (by leading @@ -1500,14 +1499,10 @@ def metric_trend( "value". A snapshot that raises logs at debug and stores None instead of aborting the whole series. """ - import importlib from datetime import datetime, timezone + from pyegeria.view.format_set_executor import resolve_report_spec_function # lazy: keeps the design boundary - module_path, func_name = metric_path.rsplit(".", 1) - module = importlib.import_module(module_path) - func = getattr(module, func_name, None) - if func is None or not callable(func): - raise AttributeError(f"'{func_name}' not found in module '{module_path}'") + func = resolve_report_spec_function(metric_path) clients = {"mgr": mgr, "ce": ce} bound_args = [] diff --git a/tests/micro-tests/test_issue_173_registered_names.py b/tests/micro-tests/test_issue_173_registered_names.py new file mode 100644 index 00000000..391f8863 --- /dev/null +++ b/tests/micro-tests/test_issue_173_registered_names.py @@ -0,0 +1,91 @@ +"""ISSUE-173: report specs name analytic functions by registered name. A dotted path +still resolves this release, with a DeprecationWarning and a log line naming what +to use instead; pyegeria's own specs use names only.""" +import warnings + +import pytest + +import pyegeria.view.format_set_executor as fse +from pyegeria.view import overview_metrics +from pyegeria.view.analytic_demo_specs import get_analytic_demo_specs +from pyegeria.view.analytic_registry import clear_runtime_analytic_functions, get_analytic_registry +from pyegeria.view.base_report_formats import base_report_specs + + +@pytest.fixture(autouse=True) +def _clean(monkeypatch): + monkeypatch.delenv("PYEGERIA_ANALYTIC_MODULE_PREFIXES", raising=False) + monkeypatch.setattr(fse, "_DOTTED_PATH_WARNED", set()) + clear_runtime_analytic_functions() + yield + clear_runtime_analytic_functions() + + +@pytest.fixture +def log_lines(): + from loguru import logger + lines = [] + sink = logger.add(lambda m: lines.append(m.record), level="WARNING") + yield lines + logger.remove(sink) + + +def test_registered_name_resolves_without_warning(log_lines): + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert fse.resolve_report_spec_function("governed_coverage") is overview_metrics.governed_coverage + assert log_lines == [] + + +def test_registered_dotted_path_warns_and_names_the_registered_name(log_lines): + path = "pyegeria.view.overview_metrics.governed_coverage" + with pytest.warns(DeprecationWarning, match="use the registered name 'governed_coverage'"): + assert fse.resolve_report_spec_function(path) is overview_metrics.governed_coverage + with pytest.warns(DeprecationWarning): + fse.resolve_report_spec_function(path) + mine = [r for r in log_lines if path in r["message"]] + assert len(mine) == 1 and mine[0]["level"].name == "WARNING" # logged once, warned every time + + +def test_unregistered_dotted_path_warns_to_register_it(): + with pytest.warns(DeprecationWarning, match="'pyegeria.analytics' entry-point group"): + fse.resolve_report_spec_function("pyegeria.core.utils.body_slimmer") + + +def test_refused_path_raises_without_a_deprecation_warning(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + with pytest.raises(fse.AnalyticFunctionRefused): + fse.resolve_report_spec_function("os.system") + + +def test_metric_trend_metric_path_goes_through_the_allowlist(): + with pytest.raises(fse.AnalyticFunctionRefused): + overview_metrics.metric_trend(None, None, metric_path="os.getcwd", points=2) + + +def test_metric_trend_accepts_a_registered_name(monkeypatch): + calls = [] + monkeypatch.setattr(overview_metrics, "governed_coverage", lambda mgr, as_of=None: calls.append(as_of) or {"n": 1}) + with warnings.catch_warnings(): + warnings.simplefilter("error") + series = overview_metrics.metric_trend("mgr", "ce", metric_path="governed_coverage", points=2) + assert len(series) == 2 and len(calls) == 2 + + +def _pyegeria_spec_functions(): + specs = {**base_report_specs, **get_analytic_demo_specs()} + for label, fs in specs.items(): + action = fs.action + if action is not None and action.analytic_function: + yield label, action.analytic_function, action.analytic_spec_params or {} + + +def test_pyegeria_specs_name_registered_functions_only(): + registry = get_analytic_registry() + found = list(_pyegeria_spec_functions()) + assert found, "expected pyegeria to ship analytic report specs" + for label, name, params in found: + assert name in registry, f"{label} names {name!r}, not a registered name" + if "metric_path" in params: + assert params["metric_path"] in registry, f"{label} metric_path {params['metric_path']!r}" From d764bff84a1073c066b94fbac466e319c89bff84 Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 31 Oct 2026 07:48:14 -0500 Subject: [PATCH 2/3] Load package report specs through a pyegeria.report_specs entry-point group; deprecate the env-var sources (ISSUE-173) refresh_report_specs() reads the pyegeria.report_specs group (alias trellis.report_specs) first, each entry point isolated like any other CONFIG source. pyegeria's own analytic demo specs move to that group and leave config.json. config.json's "Pyegeria Report Spec Modules", PYEGERIA_REPORT_SPEC_MODULES and PYEGERIA_REPORT_FORMATS_JSON/_MODULES still load in 6.2.18 with one DeprecationWarning per refresh, and go in 6.2.19. Docs, CLAUDE.md, AGENTS.md and ISSUE-173 updated. Signed-off-by: Dan Wolfson --- AGENTS.md | 2 +- CLAUDE.md | 4 +- PYEGERIA_ISSUES.md | 23 +++- config/config.json | 5 +- docs/output-formats-and-report-specs.md | 44 ++++--- md_processing/dr_egeria.py | 5 +- pyegeria/view/README.md | 2 +- pyegeria/view/base_report_formats.py | 72 +++++++++-- pyproject.toml | 5 + ...test_issue_173_report_spec_entry_points.py | 120 ++++++++++++++++++ 10 files changed, 240 insertions(+), 42 deletions(-) create mode 100644 tests/micro-tests/test_issue_173_report_spec_entry_points.py diff --git a/AGENTS.md b/AGENTS.md index 42fad941..dd7868a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ - `pyegeria/omvs/*`: service implementations (40+ modules); new API behavior should usually be added here first. - `pyegeria/models/`: Pydantic models for Egeria API request/response bodies (`models.py`, `collection_models.py`). - `pyegeria/view/_output_format_models.py`: Pydantic models (`Column`/`Format`/`FormatSet`/`ActionParameter`) backing the report spec registry. -- `pyegeria/view/output_formatter.py` + `pyegeria/view/base_report_formats.py`: materialization/rendering and report spec registry; generated specs live in `base_report_formats.py` under `generated_format_sets` (do not hand-edit), hand-maintained ones under `base_report_specs` (safe to edit). `get_report_registry()` auto-loads an additional CONFIG tier from `settings.Environment.pyegeria_report_spec_modules` on first call. +- `pyegeria/view/output_formatter.py` + `pyegeria/view/base_report_formats.py`: materialization/rendering and report spec registry; generated specs live in `base_report_formats.py` under `generated_format_sets` (do not hand-edit), hand-maintained ones under `base_report_specs` (safe to edit). `get_report_registry()` auto-loads an additional CONFIG tier on first call: `pyegeria.report_specs` entry points (alias `trellis.report_specs`; pyegeria's own analytic demos load this way) and the user report specs directory, plus the deprecated `PYEGERIA_REPORT_SPEC_MODULES`/config.json list (warns in 6.2.18, removed in 6.2.19; ISSUE-173). Report specs name analytic functions by registered name; a dotted path is deprecated. - `pyegeria/view/format_set_executor.py`: side-effect-free FormatSet runner used by the MCP adapter and the `ViewProcessor` in Dr.Egeria; also runs `ActionParameter.analytic_function` (the `extra_find` path) and wraps its result as a `SERIES`/`BAR`/`PIE` Vega-Lite chart when requested. - `pyegeria/view/analytic_registry.py` + `pyegeria/view/analytic_demo_specs.py`: catalog of analytic functions (`extra_find` targets) and one real demo `FormatSet` per function. A report spec may only name a registered function or a module under `PYEGERIA_ANALYTIC_MODULE_PREFIXES` (ISSUE-169); packages register through the `pyegeria.analytics` entry-point group (ISSUE-172). - `pyegeria/view/_output_dashboard_sheet_models.py`: `DashboardSheet`/`Placement` — local (pre-Egeria-native) model for user-authored dashboards; built via `md_processing/v2/dashboard_sheet.py`'s processors (`Create Dashboard Sheet`, `Link Report to Dashboard Sheet`, `Add Text on Dashboard Sheet`). diff --git a/CLAUDE.md b/CLAUDE.md index cecb7d76..51c60312 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -268,12 +268,12 @@ Changes to `pyegeria/` (SDK API, config, report formatting) can break both. - `create_egeria_bearer_token()` / `set_bearer_token()` propagate tokens to every subclient. **`pyegeria/view/`** — output formatting and report spec registry -- `base_report_formats.py`: two separate dicts merged by `get_report_registry()` — `generated_format_sets` (auto-generated by `refresh_specs`; do not hand-edit) and `base_report_specs` (hand-maintained BUILTINS, e.g. `Referenceable`; safe to edit directly, `refresh_specs` never touches it). `get_report_registry()` also auto-loads a CONFIG tier on first call, from `settings.Environment.pyegeria_report_spec_modules` (config.json's `"Pyegeria Report Spec Modules"` list) or the `PYEGERIA_REPORT_SPEC_MODULES` env var — each entry a `.json` path or a `"pkg.mod:func"` loader — plus every `*.json` in the user report specs directory (`PYEGERIA_USER_REPORT_SPECS_DIR` > config.json `"Pyegeria User Report Specs Dir"` > `~/.pyegeria/report_specs`). Each entry loads independently: a failing or label-colliding entry is skipped with a warning (and returned by `refresh_report_specs()`), never blocking the others. **Gotcha:** `config/config.json` is only auto-discovered if `PYEGERIA_CONFIG_DIRECTORY` (or `PYEGERIA_ROOT_PATH`) is set — a bare shell with neither falls back to defaults silently, so a probe script that just imports `settings` may not see config.json values at all; use the `PYEGERIA_REPORT_SPEC_MODULES` env var directly when in doubt. +- `base_report_formats.py`: two separate dicts merged by `get_report_registry()` — `generated_format_sets` (auto-generated by `refresh_specs`; do not hand-edit) and `base_report_specs` (hand-maintained BUILTINS, e.g. `Referenceable`; safe to edit directly, `refresh_specs` never touches it). `get_report_registry()` also auto-loads a CONFIG tier on first call: every `pyegeria.report_specs` entry point (alias `trellis.report_specs`; a callable returning a FormatSetDict, or the dict itself — pyegeria's own analytic demo specs load this way, so after changing entry points run `uv sync`), then every `*.json` in the user report specs directory (`PYEGERIA_USER_REPORT_SPECS_DIR` > config.json `"Pyegeria User Report Specs Dir"` > `~/.pyegeria/report_specs`). config.json's `"Pyegeria Report Spec Modules"`, `PYEGERIA_REPORT_SPEC_MODULES` and `PYEGERIA_REPORT_FORMATS_JSON`/`_MODULES` still load but are deprecated: one DeprecationWarning per refresh in 6.2.18, removed in 6.2.19 (ISSUE-173). Each entry loads independently: a failing or label-colliding entry is skipped with a warning (and returned by `refresh_report_specs()`), never blocking the others. **Gotcha:** `config/config.json` is only auto-discovered if `PYEGERIA_CONFIG_DIRECTORY` (or `PYEGERIA_ROOT_PATH`) is set — a bare shell with neither falls back to defaults silently, so a probe script that just imports `settings` may not see config.json values at all; use the `PYEGERIA_USER_REPORT_SPECS_DIR` env var directly when in doubt. - `output_formatter.py`: `generate_output()` — materializes elements into MD/LIST/DICT/REPORT formats. - `_output_format_models.py`: Pydantic models `Column`/`Format`/`FormatSet`/`ActionParameter` — define new specs with these, not raw dicts. `ActionParameter` has two independent execution paths: `function`/`find_method` (element-query, formatted per-column) and `analytic_function`/`extra_find` (a plain function returning an already-aggregated result — see `output-formats-and-report-specs.md`'s "Analytic functions" section). - `format_set_executor.py`: `exec_report_spec()` — runs either path; `SERIES`/`BAR`/`PIE` output formats wrap an analytic function's result as a Vega-Lite chart (dispatched before normal Format-row lookup, so no `FormatSet` needs to declare those as a `Format` row). - `analytic_registry.py` / `analytic_demo_specs.py`: the catalog of analytic functions (`AnalyticFunctionSpec.generic` marks whether what it counts is a parameter or hardcoded) and one real, executable demo `FormatSet` per registered function. Installed packages add entries through the `pyegeria.analytics` entry-point group (`trellis.analytics` as an alias), read lazily on first registry use (ISSUE-172); a spec's optional `declaration` carries result kind, measures with unit/as-of/basis and the four absence sentences (ISSUE-170). -- **Gotcha — `analytic_function` can't name just any module** (ISSUE-169): it must be a registered name, a path a registered entry declares, or a path under `PYEGERIA_ANALYTIC_MODULE_PREFIXES` (default `pyegeria`, `trellis_analytics`); anything else raises `AnalyticFunctionRefused` before import. A test that injects a fake module must add it to that env var. And a `not_established`/`not_applicable` result comes back as `kind: "absent"` with a sentence in `content`, not as `data` (ISSUE-171). +- **Gotcha — `analytic_function` can't name just any module** (ISSUE-169): it must be a registered name, a path a registered entry declares, or a path under `PYEGERIA_ANALYTIC_MODULE_PREFIXES` (default `pyegeria`, `trellis_analytics`); anything else raises `AnalyticFunctionRefused` before import. Report specs (including `metric_trend`'s `metric_path`) should use the registered name: a dotted path there warns with a DeprecationWarning in 6.2.18 and is refused from 6.2.19 (ISSUE-173). A test that injects a fake module must add it to that env var. And a `not_established`/`not_applicable` result comes back as `kind: "absent"` with a sentence in `content`, not as `data` (ISSUE-171). - `_output_dashboard_sheet_models.py`: `DashboardSheet`/`Placement` — a local (not yet Egeria-native) model for user-authored dashboards, built via Dr.Egeria's `Create Dashboard Sheet`/`Link Report to Dashboard Sheet`/`Add Text on Dashboard Sheet` (`md_processing/v2/dashboard_sheet.py`). - Generated report specs follow `{Type}-DrE-{Basic|Advanced}` naming; carry an optional `family` string for discovery. Columns are the Create command's (input) attributes; display-only server-returned columns (e.g. the ISC mermaid graphs) go in `GENERATED_EXTRA_COLUMNS` in `commands/tech/gen_report_specs.py`, never in the compact spec. diff --git a/PYEGERIA_ISSUES.md b/PYEGERIA_ISSUES.md index 2463774e..72848087 100644 --- a/PYEGERIA_ISSUES.md +++ b/PYEGERIA_ISSUES.md @@ -148,7 +148,7 @@ content was changed. The numbered section list above describes the # Open Issues -**At a glance** — 2 open pyegeria items, 0 awaiting re-test, 0 open Egeria Server issues (2026-10-30: ISSUE-168, released in 6.2.16, and ISSUE-169..172, fixed for 6.2.17, moved to the Appendix. 2026-10-30: ISSUE-146's `remove_note` re-test passed, ISSUE-79's folder survey completes, ISSUE-108 did not reproduce at 200 links; all three moved to the Appendix. 2026-10-29: ISSUE-159, 132 and 85 live-verified; 12 Egeria-side fixes re-confirmed or closed on earlier passes and moved to the Appendix). +**At a glance** — 3 open pyegeria items, 0 awaiting re-test, 0 open Egeria Server issues (2026-10-31: ISSUE-173 added, report specs by registered name and the `pyegeria.report_specs` entry points. 2026-10-30: ISSUE-168, released in 6.2.16, and ISSUE-169..172, fixed for 6.2.17, moved to the Appendix. 2026-10-30: ISSUE-146's `remove_note` re-test passed, ISSUE-79's folder survey completes, ISSUE-108 did not reproduce at 200 links; all three moved to the Appendix. 2026-10-29: ISSUE-159, 132 and 85 live-verified; 12 Egeria-side fixes re-confirmed or closed on earlier passes and moved to the Appendix). **Egeria team note, 2026-10-18 (Mandy Chessell).** Saved verbatim at `~/.re-gate/EGERIA-TEAM-NOTE-2026-10-18.md`. Each entry below has a dated @@ -192,6 +192,27 @@ into the entry now, so it isn't rediscovered from scratch later. --- +### ISSUE-173: Report specs named analytic functions by dotted path and loaded package specs through env vars; added the `pyegeria.report_specs` entry-point group and deprecated both + +**Layer:** pyegeria · **Status:** built on branch `feat/analytic-registered-names`, no PR yet; targets 6.2.18, with removal in 6.2.19 · **Found:** 2026-10-31, the owner's ruling on Mandy's question 2 (portal analytics discovery), "yes to (b)", then "a" for report-spec registration (the Architect's R1, split note §11.3). + +**What:** the Portal could only find a package's analytic tiles if something set `PYEGERIA_REPORT_SPEC_MODULES` (or config.json's `"Pyegeria Report Spec Modules"`) to that package's loader, and report specs named functions by dotted path. pyegeria's own analytic demo specs depended on config.json's key too. Separately, `metric_trend`'s `metric_path`, which a caller can override, was imported with no allowlist check, bypassing ISSUE-169. + +**Change:** +- **Report-spec entry points** (`base_report_formats.py`): `refresh_report_specs()` reads the `pyegeria.report_specs` group, and `trellis.report_specs` as an alias, first. An entry point names a callable returning a `FormatSetDict` (or a dict of label to `FormatSet`), or such a dict itself. Each entry point loads in isolation: one that fails or clashes is skipped, logged, and returned by `refresh_report_specs()`, and the rest still load. +- **pyegeria's own demo specs** now load through its own entry point (`analytic_demos` in `pyproject.toml`), and the key is gone from `config/config.json`. A config entry naming the same loader as an installed entry point is treated as that entry point, not as a clash. +- **Deprecation:** config.json's list, `PYEGERIA_REPORT_SPEC_MODULES`, `PYEGERIA_REPORT_FORMATS_JSON` and `PYEGERIA_REPORT_FORMATS_MODULES` still load. When any is set, each refresh issues one `DeprecationWarning` and one WARNING log line naming them and the replacement. +- **Registered names** (`format_set_executor.resolve_report_spec_function`): a report spec's `analytic_function`, or `metric_trend`'s `metric_path`, given as a dotted path still resolves under the ISSUE-169 checks. It now raises a `DeprecationWarning` naming the registered name to use (or saying to register it), and logs once per path. A refused path raises with no deprecation warning. `metric_path` now goes through this resolver. +- **pyegeria's own specs** now use registered names: the SoA implementation summary (`control_implementation_summary`), every analytic demo spec, and the metric-trend demo's `metric_path` (`governed_coverage`). A test checks that every shipped spec names a registered function. + +**Kept as dotted paths, on purpose:** `AnalyticFunctionSpec.function`, an action's `fetch`/`analytic` steps, and `analytic_binders` factories. They are code, not report-spec data, and binder factories have no registry to name them from. + +**Downstream:** +- egeria-workspaces-fs `PyegeriaWebHandler/report_specs_handler.py` (quickstart and freshstart) names `pyegeria.view.overview_metrics.count_elements` and `.metric_trend`, with a dotted `metric_path`; it should use `count_elements` and `metric_trend`. +- trellis-analytics `portal.py`'s tile names `trellis_analytics.portal.curation_coverage_tile`, which needs a registered name, and its `report_specs` loader moves to a `pyegeria.report_specs` entry point. + +**6.2.19:** refuse dotted paths in report specs, and stop reading the four deprecated sources. + ### ISSUE-155: pyegeria gaps against Egeria `main` since 6.2 (omvs_audit + source diff, V6.2..05a5e982) **Status 2026-10-29:** items 1 (Governance Engine moves, #460), 5 (undo/restore) and 6 (usage-activity and popularity classifications) are done and released, and item 2 is fixed under ISSUE-154. Still open: item 4 (`typeName`, which only `OpenMetadataTemplateRequestBody` carries) and item 7 (`GetOptions.viewElementGUID`, low priority). diff --git a/config/config.json b/config/config.json index dac20f26..7fc4fbb5 100644 --- a/config/config.json +++ b/config/config.json @@ -26,10 +26,7 @@ "Pyegeria Root": "sample-data", "Pyegeria Config Directory": "config", "Pyegeria Publishing Root": "http://localhost:8085/outbox", - "Pyegeria User Report Specs Dir": "~/.pyegeria/report_specs", - "Pyegeria Report Spec Modules": [ - "pyegeria.view.analytic_demo_specs:get_analytic_demo_specs" - ] + "Pyegeria User Report Specs Dir": "~/.pyegeria/report_specs" }, "feature_x_enabled": false, "Logging": { diff --git a/docs/output-formats-and-report-specs.md b/docs/output-formats-and-report-specs.md index dab0a24d..515e2c26 100644 --- a/docs/output-formats-and-report-specs.md +++ b/docs/output-formats-and-report-specs.md @@ -528,42 +528,40 @@ Every report-spec lookup (`select_report_spec`, `get_report_spec_match`, `list_r |---|---| | `BUILTINS` | `base_report_specs`, hand-maintained in `base_report_formats.py` | | `GENERATED` | `generated_format_sets`, written by `refresh_specs` | -| `CONFIG` | Your files and loaders (below) | +| `CONFIG` | Installed packages' entry points, your JSON files, and (deprecated) loaders named in config (below) | | `RUNTIME` | Anything registered in code with `register_report_specs(...)` | -**Adding your own files (the `CONFIG` tier).** List as many entries as you like, in either or both of these places: +**Adding specs (the `CONFIG` tier).** There are two supported sources, plus some deprecated ones: -- `config.json`, under the key `"Pyegeria Report Spec Modules"` (a JSON list): - ```json - "Pyegeria Report Spec Modules": [ - "pyegeria.view.analytic_demo_specs:get_analytic_demo_specs", - "~/my-specs/team-reports.json", - "~/my-specs/governance-reports.json", - "my_package.specs:load_specs" - ] +- **A package's entry points (ISSUE-173).** A package contributes report specs by installing. Declare an entry point in the `pyegeria.report_specs` group that names a callable returning a `FormatSetDict` (or a plain `dict` of label to `FormatSet`), or such a dict itself: + ```toml + [project.entry-points."pyegeria.report_specs"] + my_pkg = "my_pkg.specs:report_specs" ``` -- The environment variable `PYEGERIA_REPORT_SPEC_MODULES`, as a comma-separated string. Its entries are added after the config.json list. + The `trellis.report_specs` group is read too, as an alias. Entry points load first. pyegeria's own analytic demo specs load this way (`analytic_demos`), so an editable install needs `uv sync` (or `pip install -e .`) after a change to entry points. - **The user report specs directory.** Every `*.json` file in it is loaded, in alphabetical order, after the list above. Subdirectories and non-JSON files are ignored. The directory is resolved in this order: 1. the `PYEGERIA_USER_REPORT_SPECS_DIR` env var (or the older `PYEGERIA_USER_FORMAT_SETS_DIR`); 2. config.json's `"Pyegeria User Report Specs Dir"`; 3. `~/.pyegeria/report_specs`. - If the directory doesn't exist, it's skipped quietly. A file that is also named explicitly in the list is loaded only once. + If the directory doesn't exist, it's skipped quietly. A file that is also named explicitly in a deprecated list below is loaded only once. -Each list entry is one of: +- **Deprecated in 6.2.18, removed in 6.2.19:** config.json's `"Pyegeria Report Spec Modules"` list, and the env vars `PYEGERIA_REPORT_SPEC_MODULES`, `PYEGERIA_REPORT_FORMATS_JSON` and `PYEGERIA_REPORT_FORMATS_MODULES`. They still load, after the entry points. When any is set, each refresh issues one `DeprecationWarning` and one WARNING log line naming them. Move a package's loader to an entry point, and JSON files to the user report specs directory. A config entry that names the same loader as an installed entry point (such as an older config.json listing `pyegeria.view.analytic_demo_specs:get_analytic_demo_specs`) is treated as that entry point, not as a clash, but still warns. + +Each entry in a deprecated list is one of: - **A path ending in `.json`** (`~` is expanded). The file holds a dictionary of FormatSets keyed by label; see [JSON structure for user specs](#json-structure-for-user-specs). `save_report_specs(...)` writes files in this shape. - **A loader reference**, `"pkg.mod:func"` or `"pkg.mod.func"`. The function is imported and called with no arguments, and must return a `FormatSetDict` or a plain `dict` of `FormatSet`s. -Two older comma-separated env vars are still read, after the list above: `PYEGERIA_REPORT_FORMATS_JSON` (JSON paths only) and `PYEGERIA_REPORT_FORMATS_MODULES` (loaders only). +`PYEGERIA_REPORT_FORMATS_JSON` takes JSON paths only and `PYEGERIA_REPORT_FORMATS_MODULES` loaders only; both are read after the user report specs directory. **When entries are loaded.** The `CONFIG` tier loads automatically the first time anything calls `get_report_registry()`, so no consumer needs to register anything. It loads once per process. If you add or edit a file while a process is running, call `refresh_report_specs()` to reload. `load_user_report_specs()` does the same reload and also copies the directory's files into the legacy `report_specs` dict, for older code that reads that dict directly. **What happens when an entry is bad.** Each entry loads on its own, and a bad entry is skipped as a whole. Every other entry still loads, before and after it. An entry is skipped when: - its file doesn't exist or isn't valid report-spec JSON; -- its module or function can't be imported, or the function raises an error; +- its entry point, module or function can't be imported, or the function raises an error; - it defines a label that another source already defined (an earlier `CONFIG` entry, a built-in, a generated spec, or a `RUNTIME` registration). Labels must be unique across the registry, so nothing is silently overridden. The first entry to claim a label keeps it. Each skip is logged as a warning (`Skipping report spec source JSON:: `). `refresh_report_specs()` also returns the skips as a list of `(source, reason)` pairs, with an empty list meaning everything loaded. Use that to check your configuration: @@ -574,7 +572,7 @@ for source, reason in refresh_report_specs(): print(f"{source} was not loaded: {reason}") ``` -**Gotcha: `config.json` must be discoverable.** pyegeria only finds `config/config.json` if `PYEGERIA_CONFIG_DIRECTORY` (or `PYEGERIA_ROOT_PATH`) is set. Otherwise its `"Pyegeria Report Spec Modules"` and `"Pyegeria User Report Specs Dir"` settings are silently ignored. If you're not sure, use the env vars. +**Gotcha: `config.json` must be discoverable.** pyegeria only finds `config/config.json` if `PYEGERIA_CONFIG_DIRECTORY` (or `PYEGERIA_ROOT_PATH`) is set. Otherwise its `"Pyegeria User Report Specs Dir"` setting (and the deprecated `"Pyegeria Report Spec Modules"`) is silently ignored. If you're not sure, use `PYEGERIA_USER_REPORT_SPECS_DIR`. **Changed behavior in the user specs directory.** Before, directory files could override a built-in spec, and a later file overwrote an earlier file's label. Neither is true any more, because the directory is now part of the `CONFIG` tier: a file that reuses an existing label is skipped with a warning. To change a built-in spec, save it under a new label. Also, before this fix the directory defaulted to `../`, the parent of the current working directory; it now defaults to `~/.pyegeria/report_specs`. @@ -949,7 +947,7 @@ Tip: You can combine `find_report_specs` to discover candidates and then `run_re A `FormatSet`'s `action` (`ActionParameter`, in `pyegeria/view/_output_format_models.py`) has **two independent execution paths**, mirroring the two function slots Dr.Egeria commands have always carried (`find_method`/`find_constraints` and `extra_find`/`extra_constraints`): - `function` (from `find_method`): an element-query call, typically a `pyegeria.omvs.*` method (e.g. `MetadataExpert.find_metadata_elements_with_string`) — returns a list of Egeria elements, formatted per-column by the `FormatSet`'s `Format.attributes` (the TABLE/DICT/REPORT/MERMAID path already documented above). -- `analytic_function` (from `extra_find`): a registered analytic function's name, or the dotted path of a plain Python function — typically in `pyegeria.view.overview_metrics` — that takes an already-constructed, already-authenticated client as its first argument (or first two, for a function needing two client roles — see `_bind_client_args` in `pyegeria/view/format_set_executor.py`) and returns an already-aggregated scalar/dict/list result, **not** raw elements. Deliberately no fixed required/optional-param whitelist: every caller-supplied param is forwarded as-is (`**kwargs`); Python's own `TypeError` on an unexpected keyword is the validation. Which names and paths are accepted is described under [Which functions a report spec may name](#which-functions-a-report-spec-may-name). +- `analytic_function` (from `extra_find`): a registered analytic function's name (a dotted path is deprecated; see below), naming a plain Python function — typically in `pyegeria.view.overview_metrics` — that takes an already-constructed, already-authenticated client as its first argument (or first two, for a function needing two client roles — see `_bind_client_args` in `pyegeria/view/format_set_executor.py`) and returns an already-aggregated scalar/dict/list result, **not** raw elements. Deliberately no fixed required/optional-param whitelist: every caller-supplied param is forwarded as-is (`**kwargs`); Python's own `TypeError` on an unexpected keyword is the validation. Which names and paths are accepted is described under [Which functions a report spec may name](#which-functions-a-report-spec-may-name). A `FormatSet` can declare either path, or both (in which case the analytic path only runs for `SERIES`/`BAR`/`PIE` output formats — see below — and the find path handles everything else, unchanged). @@ -1022,12 +1020,14 @@ A name defined twice raises `AnalyticRegistryCollision`, except that an installe A report spec is data: it can come from a user's report-spec directory or a config file. So `exec_report_spec` does not import whatever it names (ISSUE-169). `analytic_function`, an action's `fetch`/`analytic` steps and `analytic_binders` factories are resolved in this order: -1. **A registered name** resolves to that entry's `function`. This is the preferred form. -2. **A dotted path that a registered entry declares** is trusted. +1. **A registered name** resolves to that entry's `function`. This is the form report specs use. +2. **A dotted path that a registered entry declares** is trusted, but deprecated in a report spec (below). 3. **Any other dotted path** must be in a module under an allowed prefix. The defaults are `pyegeria` and `trellis_analytics`. Set `PYEGERIA_ANALYTIC_MODULE_PREFIXES` (comma-separated) to change the list. A prefix is a package boundary: `pyegeria` admits `pyegeria.view.x`, not `pyegeria_extras.x`. These resolve as before and log at DEBUG. 4. **Anything else** raises `AnalyticFunctionRefused` (a `ValueError`) before anything is imported. -To use a function from your own package, register it (any tier above), or add your package to `PYEGERIA_ANALYTIC_MODULE_PREFIXES`. Registering is better: the function then appears in the registry for authoring tools, and it can carry a declaration. +To use a function from your own package, register it (any tier above, usually an entry point), or add your package to `PYEGERIA_ANALYTIC_MODULE_PREFIXES`. Registering is better: the function then appears in the registry for authoring tools, and it can carry a declaration. + +**Report specs name functions by registered name (ISSUE-173).** In 6.2.18 a report spec's `analytic_function`, or a function-valued parameter such as `metric_trend`'s `metric_path`, that is a dotted path (steps 2 and 3) still resolves, but raises a `DeprecationWarning` naming the registered name to use (or, if none, saying to register it), and logs it once per path at WARNING. 6.2.19 will refuse them. A path refused at step 4 raises without the deprecation warning. Registry entries themselves (`AnalyticFunctionSpec.function`, an action's `fetch`/`analytic`) and `analytic_binders` factories keep their dotted paths: those are code, not report-spec data. `metric_path` used to be imported with no check at all; it now goes through this resolver. #### Declaring what results mean @@ -1075,11 +1075,13 @@ my_pkg = "my_pkg.manifests:ANALYTICS" - **When it's read:** lazily, on the first `get_analytic_registry()` call, and cached; `refresh_analytic_functions()` forgets the cache. - **What loads:** only the manifests load then. Each function's own module is imported on its first run, so keep the manifest module free of heavy imports. - **Resolver:** a registered function is trusted whatever its module, so installing is all a package needs to do. +- **Report specs too:** a package that also ships report specs for its functions declares them in the `pyegeria.report_specs` group (see [Where pyegeria looks for user specs](#where-pyegeria-looks-for-user-specs)), and those specs name the functions by registered name. +- **Discovering what's installed:** `get_analytic_registry()` returns every function, including entry-point ones, and `analytic_registry_payload()` gives the JSON a UI lists. `get_report_registry()` returns every report spec, including entry-point ones. Both read entry points on first call; `refresh_analytic_functions()` and `refresh_report_specs()` re-read them. - **Failures:** an entry point that fails to load, or names something that isn't a spec, is reported by `analytic_entry_point_errors()` and in `analytic_registry_payload()["entry_point_errors"]`. It is logged at WARNING and never stops the rest loading, so "installed but not loadable" is never shown as "nothing installed". **Demo report specs** (`pyegeria/view/analytic_demo_specs.py`) — one real, executable `FormatSet` per registered analytic function, proving each is actually runnable as a report spec (not just documented in the registry). Named `Analytic Demo - `, family `"Analytic Function Demo"`. Each spec's `description` states plainly whether the underlying function is generic or a fixed metric (pulled from the registry, not duplicated by hand). -**Making extra report specs visible everywhere** — the demo specs are loaded through the registry's `CONFIG` tier. That tier takes any number of JSON files and loader functions, and one bad entry is skipped without affecting the rest; see [Where pyegeria looks for user specs](#where-pyegeria-looks-for-user-specs). `config/config.json` ships with `"Pyegeria Report Spec Modules": ["pyegeria.view.analytic_demo_specs:get_analytic_demo_specs"]` by default, so the demo specs above are visible to `dr_egeria`, `hey_egeria`, and any other pyegeria consumer without further setup — a consumer with no `config.json` of its own (e.g. a container with only the installed package) can set the `PYEGERIA_REPORT_SPEC_MODULES` env var instead. See `refresh_report_specs()`/`get_report_registry()` in `base_report_formats.py`. +**Making extra report specs visible everywhere** — the demo specs are loaded through the registry's `CONFIG` tier, by pyegeria's own `pyegeria.report_specs` entry point (`analytic_demos`, ISSUE-173). Any installed pyegeria therefore shows them to `dr_egeria`, `hey_egeria`, and every other consumer, with no config.json or env var. Another package's specs load the same way; one bad entry is skipped without affecting the rest. See [Where pyegeria looks for user specs](#where-pyegeria-looks-for-user-specs) and `refresh_report_specs()`/`get_report_registry()` in `base_report_formats.py`. **Dashboard authoring** — a `Report` (a real Egeria asset naming a report spec plus default execution params, created via Dr.Egeria's `Create Report`) can be placed on a user-authored Dashboard Sheet (`Create Dashboard Sheet` / `Link Report to Dashboard Sheet`), alongside literal markdown text placements (`Add Text on Dashboard Sheet`) for section headers and explanations. See the "Report" family in `docs/dr_egeria_manual.md` and `pyegeria/view/_output_dashboard_sheet_models.py`. diff --git a/md_processing/dr_egeria.py b/md_processing/dr_egeria.py index fb9ff172..ee860f9b 100644 --- a/md_processing/dr_egeria.py +++ b/md_processing/dr_egeria.py @@ -5,9 +5,8 @@ # Analytic Function Demo report specs (pyegeria.view.analytic_demo_specs) -- # and any other extra report-spec source -- are auto-loaded by -# get_report_registry() itself (CONFIG tier, driven by -# settings.Environment.pyegeria_report_spec_modules / config.json's "Pyegeria -# Report Spec Modules") the first time anything calls it. No explicit +# get_report_registry() itself (CONFIG tier, through pyegeria's own +# `pyegeria.report_specs` entry point, ISSUE-173) the first time anything calls it. No explicit # registration call needed here anymore. """ This is an ongoing experiment in parsing and playing with Dr.Egeria docs diff --git a/pyegeria/view/README.md b/pyegeria/view/README.md index b3fcb888..3a16ded0 100644 --- a/pyegeria/view/README.md +++ b/pyegeria/view/README.md @@ -8,7 +8,7 @@ into MD/LIST/DICT/REPORT/MERMAID/chart output. | File | Role | |---|---| -| `base_report_formats.py` | Two dicts merged by `get_report_registry()`: `generated_format_sets` (auto-generated by `refresh_specs` — do not hand-edit) and `base_report_specs` (hand-maintained built-ins, e.g. `Referenceable` — `refresh_specs` never touches it). Also auto-loads a CONFIG tier from `settings.Environment.pyegeria_report_spec_modules`/`PYEGERIA_REPORT_SPEC_MODULES`. | +| `base_report_formats.py` | Two dicts merged by `get_report_registry()`: `generated_format_sets` (auto-generated by `refresh_specs` — do not hand-edit) and `base_report_specs` (hand-maintained built-ins, e.g. `Referenceable` — `refresh_specs` never touches it). Also auto-loads a CONFIG tier from `pyegeria.report_specs` entry points and the user report specs directory; `PYEGERIA_REPORT_SPEC_MODULES` and config.json's list are deprecated (ISSUE-173). | | `_output_format_models.py` | Pydantic models `Column`/`Format`/`FormatSet`/`ActionParameter` — define new report specs with these, not raw dicts. | | `output_formatter.py` | `generate_output()` — materializes elements into MD/LIST/DICT/REPORT formats. | | `format_set_executor.py` | `exec_report_spec()` — runs a `FormatSet`'s query/render path; dispatches `SERIES`/`BAR`/`PIE` output formats to Vega-Lite chart rendering before normal Format-row lookup. | diff --git a/pyegeria/view/base_report_formats.py b/pyegeria/view/base_report_formats.py index e9669c4c..7a6da176 100644 --- a/pyegeria/view/base_report_formats.py +++ b/pyegeria/view/base_report_formats.py @@ -79,9 +79,11 @@ import os import re as _re +import warnings from datetime import datetime, timezone +from importlib.metadata import entry_points from pathlib import Path -from typing import Callable, List, Optional, Tuple, Union +from typing import Any, Callable, List, Optional, Tuple, Union from loguru import logger @@ -3847,11 +3849,42 @@ def _split_env_list(name: str) -> List[str]: return [p.strip() for p in os.getenv(name, "").split(",") if p.strip()] +# Installed packages contribute report specs through this entry-point group (ISSUE-173), +# the same way they contribute analytic functions through `pyegeria.analytics`. +REPORT_SPEC_ENTRY_POINT_GROUP = "pyegeria.report_specs" +REPORT_SPEC_ENTRY_POINT_ALIASES = ("trellis.report_specs",) + +_DEPRECATED_SPEC_SOURCES_MESSAGE = ( + "Loading report specs through {sources} is deprecated and will be removed in the next release. " + "Register a package's specs through the 'pyegeria.report_specs' entry-point group instead, " + "or put JSON files in the user report specs directory.") + + +def _load_entry_point(ep) -> FormatSetDict: + loaded = ep.load() + if callable(loaded) and not isinstance(loaded, dict): + loaded = loaded() + return loaded if isinstance(loaded, FormatSetDict) else FormatSetDict(loaded) + + +def _report_spec_entry_points() -> list: + found = [] + for group in (REPORT_SPEC_ENTRY_POINT_GROUP, *REPORT_SPEC_ENTRY_POINT_ALIASES): + found.extend((group, ep) for ep in entry_points(group=group)) + return found + + def refresh_report_specs() -> List[Tuple[str, str]]: """Reload formats from configured JSON files and optional modules. Sources, in order: - - `settings.Environment.pyegeria_report_spec_modules` (config.json's + - Every entry point in the `pyegeria.report_specs` group (and its alias + `trellis.report_specs`). Its value is a callable returning a + FormatSetDict (or a dict of label to FormatSet), or such a dict itself. + This is how pyegeria's own analytic demo specs load (ISSUE-173). + - Deprecated (ISSUE-173; removed in the next release), with one + DeprecationWarning per refresh when any is set: + `settings.Environment.pyegeria_report_spec_modules` (config.json's "Pyegeria Report Spec Modules" list, or the OS env var PYEGERIA_REPORT_SPEC_MODULES as a comma-separated string) -- each entry is a JSON file path (ending ".json") or a "pkg.mod:func"/ @@ -3860,10 +3893,9 @@ def refresh_report_specs() -> List[Tuple[str, str]]: (PYEGERIA_USER_REPORT_SPECS_DIR / PYEGERIA_USER_FORMAT_SETS_DIR, else config.json's "Pyegeria User Report Specs Dir", else ~/.pyegeria/report_specs). - - PYEGERIA_REPORT_FORMATS_JSON: comma-separated JSON file paths (older, - still supported for backward compat). - - PYEGERIA_REPORT_FORMATS_MODULES: comma-separated module callables - (older, still supported for backward compat). + - Deprecated, as above: PYEGERIA_REPORT_FORMATS_JSON (comma-separated + JSON file paths) and PYEGERIA_REPORT_FORMATS_MODULES (comma-separated + module callables). Each entry is loaded independently: an entry that fails to load, or that defines a label already present (in an earlier entry, or in the BUILTINS/ @@ -3879,17 +3911,39 @@ def refresh_report_specs() -> List[Tuple[str, str]]: configured = list(settings.Environment.pyegeria_report_spec_modules or []) except Exception: # noqa: BLE001 -- settings may not be initialized in every context configured = [] + config_list = list(configured) configured.extend(_split_env_list("PYEGERIA_REPORT_SPEC_MODULES")) + legacy_json = _split_env_list("PYEGERIA_REPORT_FORMATS_JSON") + legacy_modules = _split_env_list("PYEGERIA_REPORT_FORMATS_MODULES") + + entries: List[Tuple[str, Any, Callable[[Any], FormatSetDict]]] = [] + ep_values = set() + for group, ep in _report_spec_entry_points(): + ep_values.add(ep.value.replace(" ", "")) + entries.append((f"ENTRY_POINT:{group}:{ep.name}", ep, _load_entry_point)) + # A config entry naming a loader an entry point already provides (e.g. an older + # config.json listing pyegeria's demo specs) is the same source, not a clash. + configured = [e for e in configured if e.replace(" ", "") not in ep_values] + legacy_modules = [m for m in legacy_modules if m.replace(" ", "") not in ep_values] + + deprecated = [name for name, used in ( + ('config.json "Pyegeria Report Spec Modules"', config_list), + ("PYEGERIA_REPORT_SPEC_MODULES", _split_env_list("PYEGERIA_REPORT_SPEC_MODULES")), + ("PYEGERIA_REPORT_FORMATS_JSON", legacy_json), + ("PYEGERIA_REPORT_FORMATS_MODULES", legacy_modules)) if used] + if deprecated: + message = _DEPRECATED_SPEC_SOURCES_MESSAGE.format(sources=", ".join(deprecated)) + warnings.warn(message, DeprecationWarning, stacklevel=2) + logger.warning(message) - entries: List[Tuple[str, str, Callable[[str], FormatSetDict]]] = [] for entry in configured: if entry.endswith(".json"): entries.append((f"JSON:{entry}", entry, _load_json_file)) else: entries.append((f"MODULE:{entry}", entry, _load_module_loader)) entries.extend((f"JSON:{p}", p, _load_json_file) for p in _user_report_spec_files()) - entries.extend((f"JSON:{p}", p, _load_json_file) for p in _split_env_list("PYEGERIA_REPORT_FORMATS_JSON")) - entries.extend((f"MODULE:{m}", m, _load_module_loader) for m in _split_env_list("PYEGERIA_REPORT_FORMATS_MODULES")) + entries.extend((f"JSON:{p}", p, _load_json_file) for p in legacy_json) + entries.extend((f"MODULE:{m}", m, _load_module_loader) for m in legacy_modules) # A CONFIG label that duplicates another tier would make get_report_registry() # itself raise, taking every report spec down with it -- reject it here instead. diff --git a/pyproject.toml b/pyproject.toml index ea59a6e0..34a9a819 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -193,6 +193,11 @@ dependencies = [ refresh_specs = "commands.tech.refresh_specs:main" dr_egeria_spec_editor = "commands.tech.spec_editor:main" +# pyegeria's own analytic demo specs load through the same entry-point group as any +# installed package's report specs (ISSUE-173). +[project.entry-points."pyegeria.report_specs"] +analytic_demos = "pyegeria.view.analytic_demo_specs:get_analytic_demo_specs" + [tool.uv.workspace] members = ["my_egeria"] diff --git a/tests/micro-tests/test_issue_173_report_spec_entry_points.py b/tests/micro-tests/test_issue_173_report_spec_entry_points.py new file mode 100644 index 00000000..2bec418e --- /dev/null +++ b/tests/micro-tests/test_issue_173_report_spec_entry_points.py @@ -0,0 +1,120 @@ +"""ISSUE-173: installed packages contribute report specs through the +`pyegeria.report_specs` entry-point group (alias `trellis.report_specs`). The +env-var and config.json sources still load this release, with one +DeprecationWarning per refresh.""" +import warnings +from importlib.metadata import EntryPoint + +import pytest + +import pyegeria.view.base_report_formats as brf +from pyegeria.core.config import settings +from pyegeria.view._output_format_models import Column, Format, FormatSet, FormatSetDict + +pytestmark = pytest.mark.unit + +DEMO_LOADER = "pyegeria.view.analytic_demo_specs:get_analytic_demo_specs" + + +def _spec(heading: str) -> FormatSet: + return FormatSet(heading=heading, description=heading, + formats=[Format(types=["DICT"], attributes=[Column(name="Display Name", key="displayName")])]) + + +def tile_specs(): + return FormatSetDict({"EP-Tile": _spec("EP-Tile")}) + + +def tile_specs_plain_dict(): + return {"EP-Plain": _spec("EP-Plain")} + + +TILE_OBJECT = FormatSetDict({"EP-Object": _spec("EP-Object")}) + + +def clashing_specs(): + return FormatSetDict({"Referenceable": _spec("clash")}) + + +def broken_specs(): + raise RuntimeError("cannot build specs") + + +def _ep(name: str, value: str, group: str = brf.REPORT_SPEC_ENTRY_POINT_GROUP) -> tuple: + return group, EntryPoint(name=name, value=value, group=group) + + +@pytest.fixture +def isolated(monkeypatch, tmp_path): + monkeypatch.setattr(settings.Environment, "pyegeria_report_spec_modules", []) + for var in ("PYEGERIA_REPORT_SPEC_MODULES", "PYEGERIA_REPORT_FORMATS_JSON", "PYEGERIA_REPORT_FORMATS_MODULES", + "PYEGERIA_USER_FORMAT_SETS_DIR"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("PYEGERIA_USER_REPORT_SPECS_DIR", str(tmp_path)) + monkeypatch.setattr(brf, "_CONFIG_REPORT_FORMATS", FormatSetDict()) + monkeypatch.setattr(brf, "_config_report_specs_loaded", True) + monkeypatch.setattr(brf, "_report_spec_entry_points", lambda: []) + return monkeypatch + + +def _entry_points(isolated, *eps): + isolated.setattr(brf, "_report_spec_entry_points", lambda: list(eps)) + + +def _refresh_no_deprecation(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + skipped = brf.refresh_report_specs() + assert not [w for w in caught if w.category is DeprecationWarning] + return skipped + + +def test_entry_point_specs_load_in_each_shape(isolated): + mod = __name__ + _entry_points(isolated, _ep("tile", f"{mod}:tile_specs"), _ep("plain", f"{mod}:tile_specs_plain_dict"), + _ep("object", f"{mod}:TILE_OBJECT", group="trellis.report_specs")) + assert _refresh_no_deprecation() == [] + registry = brf.get_report_registry() + assert {"EP-Tile", "EP-Plain", "EP-Object"} <= set(registry) + + +def test_broken_or_clashing_entry_point_is_skipped_and_the_rest_load(isolated): + mod = __name__ + _entry_points(isolated, _ep("broken", f"{mod}:broken_specs"), _ep("clash", f"{mod}:clashing_specs"), + _ep("tile", f"{mod}:tile_specs")) + skipped = dict(_refresh_no_deprecation()) + assert "cannot build specs" in skipped[f"ENTRY_POINT:{brf.REPORT_SPEC_ENTRY_POINT_GROUP}:broken"] + assert "Referenceable" in skipped[f"ENTRY_POINT:{brf.REPORT_SPEC_ENTRY_POINT_GROUP}:clash"] + assert "EP-Tile" in brf.get_report_registry() + + +@pytest.mark.parametrize("source", [ + "PYEGERIA_REPORT_SPEC_MODULES", "PYEGERIA_REPORT_FORMATS_MODULES", "PYEGERIA_REPORT_FORMATS_JSON", "config.json"]) +def test_each_deprecated_source_still_loads_and_warns(isolated, tmp_path, source): + if source == "PYEGERIA_REPORT_FORMATS_JSON": + path = tmp_path / "legacy" / "specs.json" + path.parent.mkdir() + tile_specs().save_to_json(str(path)) + isolated.setenv(source, str(path)) + elif source == "config.json": + isolated.setattr(settings.Environment, "pyegeria_report_spec_modules", [f"{__name__}:tile_specs"]) + else: + isolated.setenv(source, f"{__name__}:tile_specs") + name = 'config.json "Pyegeria Report Spec Modules"' if source == "config.json" else source + with pytest.warns(DeprecationWarning, match=f"{name}.*'pyegeria.report_specs' entry-point group"): + assert brf.refresh_report_specs() == [] + assert "EP-Tile" in brf.get_report_registry() + + +def test_config_entry_naming_an_entry_point_loader_is_the_same_source(isolated): + _entry_points(isolated, _ep("tile", f"{__name__}:tile_specs")) + isolated.setattr(settings.Environment, "pyegeria_report_spec_modules", [f"{__name__}:tile_specs"]) + with pytest.warns(DeprecationWarning): # still told to drop the key... + skipped = brf.refresh_report_specs() + assert skipped == [] # ...but it is not reported as a clash with itself + assert "EP-Tile" in brf.get_report_registry() + + +def test_pyegeria_ships_its_demo_specs_as_an_entry_point(): + eps = [ep for _, ep in brf._report_spec_entry_points() if ep.value == DEMO_LOADER] + assert eps, "run `uv sync` so the installed metadata carries pyegeria's own entry points" From 6365d25fd85b6dce51032031d4e227cd18ee4cbc Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Sat, 31 Oct 2026 08:47:57 -0500 Subject: [PATCH 3/3] chore(release): bump version to 6.2.18 Signed-off-by: Dan Wolfson --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 34a9a819..b1fdacde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ [project] name = "pyegeria" -version = "6.2.17" +version = "6.2.18" license = 'Apache-2.0' license-files = ["LICENSE"] authors = [{name = "Dan Wolfson", email = "dan.wolfson@pdr-associates.com"},] diff --git a/uv.lock b/uv.lock index 257c4042..276b8fe0 100644 --- a/uv.lock +++ b/uv.lock @@ -2238,7 +2238,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/f8/f6/8f49005d581cff883 [[package]] name = "pyegeria" -version = "6.2.17" +version = "6.2.18" source = { editable = "." } dependencies = [ { name = "altair" },