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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Python ≥3.12):
- `policy.py` — the pre-flight "honesty rule": fixed-unmappable recipe features
are dropped with a recorded warning; anything *flagged for refinement* that
can't be represented raises `errors.EasyDiffractionTranslationError`. Also
validates space groups via `topas.symmetry.cell_constraints`. Powers
validates space groups via `powderline.symmetry.cell_constraints`. Powers
`validate_only` with **no easydiffraction import**.
- `builder.py` — recipe dict → easydiffraction `Project` + a manifest mapping
every freed parameter back to GSAS-II naming/units (`scale_to_recipe`); cell
Expand Down Expand Up @@ -105,6 +105,10 @@ The 6-name public API is in `src/powderline/__init__.py` `__all__`:
- `pixi run mp-simulate --material-id mp-2680` — simulate a pattern from
Materials Project (`--formula`, `--wavelength`, `--no-server`, `--keep-recipe`).
- `pixi run docs` / `pixi run docs-clean` — build / clean the Sphinx HTML docs.
- `pixi reinstall powderline` — required after editing `[project.entry-points]` in
`pyproject.toml`: entry-point metadata is written at install time, and the gateway
registry (`powderline.registry`, group `powderline.gateways`) finds nothing until
the editable package is re-installed.
- `pixi run update-code-hash` — regenerate `src/powderline/_code_hash.json` after
editing `kicker.py` (guarded by `tests/test_code_hash.py`).

Expand Down
32 changes: 32 additions & 0 deletions docs/known_issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,35 @@ supported topology. Needs protocol versioning for already-running servers.

**Revisit.** Next branch touching the server protocol, or when the HPC
deployment work starts.

---

## KI-13 — Engine dispatch depends on install-time entry-point metadata `watch · minor`

**What.** Since re/02, `powderline.run()` resolves every engine through the
gateway registry. That includes the default `engine="gsasii"` and the in-repo
gateways. The registry reads the `powderline.gateways` entry points from the
installed distribution's metadata. If a checkout's editable install predates
the `[project.entry-points]` table, is half-installed, or powderline is
imported from source (`PYTHONPATH=src`) with no dist-info at all, then every
`run()` call fails with `GatewayNotInstalledError`. Before re/02 the
dispatcher imported the backends directly and did not depend on install
metadata. The error message is clear and names the fix. However, each existing
dev environment has to be re-installed once (`pixi reinstall powderline`).
During re/02 the maintainer's `easydiff` env was left half-installed with no
error shown, and it failed this way.

**Evidence.** `src/powderline/engine.py:53` (`registry.get(...)` for all
engines); `src/powderline/registry.py` `get` / `_not_installed_message`;
`CLAUDE.md` (the `pixi reinstall powderline` note); the execution-log re/02
entry (env notes).

**Decision.** Keep entry points as the **only** discovery path (A17, A58): the
in-repo gateways use exactly the plugin mechanism a third-party engine will
use, and a hard-coded fallback would add a second discovery path and hide a
stale install. The registry's error already names the fix. re/08 adds
install-health guidance (and possibly a check command); re/10 documents the
re-install step for contributors.

**Revisit.** re/08 (entry points finalized) and re/10 (migration guide / dev
docs).
1 change: 1 addition & 0 deletions pixi.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ fastapi = ">=0.115.0,<1"
uvicorn = ">=0.34.0,<1"
httpx = ">=0.28.0,<1"
gemmi = ">=0.7.5,<0.8"
packaging = ">=22"
# GSAS-II is built as a conda package via pixi-build (see `preview` above) from
# the tacaswell fork, which adds the conda recipe. This installs GSAS-II on ALL
# platforms (linux-64/win-64/osx-arm64), unlike the former linux-64-only pip pin.
Expand Down
10 changes: 10 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,18 @@ dependencies = [
"uvicorn>=0.34",
"httpx>=0.28",
"gemmi>=0.7,<0.8",
"packaging>=22",
]

# Gateway plugins (master plan A17): each entry targets an engine-free factory
# module. Entry-point metadata is written at install time -- after editing this
# table, re-install the editable package (`pixi reinstall powderline`) or the
# registry finds no gateways.
[project.entry-points."powderline.gateways"]
gsasii = "powderline.gateways.gsasii.gateway:get_gateway"
topas = "powderline.topas.gateway:get_gateway"
easydiffraction = "powderline.easydiff.gateway:get_gateway"

[project.urls]
Homepage = "https://github.com/NSLS2/PowderLine"
Documentation = "https://nsls2.github.io/PowderLine/"
Expand Down
2 changes: 1 addition & 1 deletion src/powderline/easydiff/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from powderline.easydiff.errors import EasyDiffractionTranslationError
from powderline.easydiff.policy import check_unsupported, param_flag, param_value
from powderline.topas.errors import TopasTranslationError
from powderline.topas.symmetry import cell_constraints
from powderline.symmetry import cell_constraints


@dataclass
Expand Down
78 changes: 78 additions & 0 deletions src/powderline/easydiff/gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Engine-free factory for the easydiffraction gateway (entry point
``powderline.gateways:easydiffraction``).

- **Schema layer** — ``validate`` / ``capabilities``: builds the ``RecipeModel``
then runs the policy pre-flight, exactly the check ``validate_only`` runs today
(raises ``EasyDiffractionTranslationError``; no easydiffraction import).
- **Runtime layer** — ``run``: a thin wrapper over
``powderline.easydiff.engine.run_easydiffraction_recipe`` (behavior unchanged).
On a real run it first reads the installed easydiffraction version: missing ⇒
``EngineNotAvailableError``; outside ``ENGINE_VERSION_SPEC`` ⇒
``EngineVersionError``.
"""

from __future__ import annotations

from powderline.exceptions import EngineNotAvailableError
from powderline.registry import Gateway, check_engine_version
from powderline.schema import RecipeModel

NAME = "easydiffraction"
#: Supported easydiffraction versions (A41/A44); mirrors the pixi pin.
ENGINE_VERSION_SPEC = ">=0.20.1,<0.21"
#: Workflows served (0.26.0 schema name until re/06; no SPF — A13.3).
WORKFLOWS = ("GSASII_Rietveld",)
#: pixi environment providing easydiffraction + how to install it (renamed in re/08, A42).
ENV = "easydiff"
INSTALL_COMMAND = "pixi install -e easydiff"


def capabilities() -> dict:
return {"name": NAME, "workflows": WORKFLOWS, "engine_version_spec": ENGINE_VERSION_SPEC}


def validate(recipe, *, verbose: bool = False):
"""Validate a recipe (dict or ``RecipeModel``); return the ``RecipeModel`` (A50, A59).

``verbose`` is part of the uniform gateway contract; this gateway prints
nothing extra today.
"""
from powderline.easydiff.policy import check_unsupported

model = recipe if isinstance(recipe, RecipeModel) else RecipeModel.model_validate(recipe)
check_unsupported(model.model_dump()) # may raise EasyDiffractionTranslationError
return model


def installed_engine_version() -> str:
"""The installed easydiffraction version (distribution == runtime there)."""
from importlib.metadata import PackageNotFoundError, version

try:
return version("easydiffraction")
except PackageNotFoundError as exc:
raise EngineNotAvailableError(gateway=NAME, dependency="easydiffraction", env=ENV,
install_command=INSTALL_COMMAND) from exc


def run(recipe, output_dir, *, verbose: bool = False, validate_only: bool = False,
allow_unsupported_engine_version: bool = False) -> dict:
"""Run through ``run_easydiffraction_recipe``; see its docstring for the result dict.

``allow_unsupported_engine_version`` is the test-only bypass of the engine
version check (A53); the check is skipped for ``validate_only``.
"""
if not validate_only:
check_engine_version(gateway=NAME, engine="easydiffraction",
installed=installed_engine_version(), spec=ENGINE_VERSION_SPEC,
allow_unsupported=allow_unsupported_engine_version)
# Bind the module (not the function) so tests can monkeypatch
# ``run_easydiffraction_recipe`` on it.
from powderline.easydiff import engine as _easydiff_engine

return _easydiff_engine.run_easydiffraction_recipe(recipe, output_dir, verbose=verbose,
validate_only=validate_only)


def get_gateway() -> Gateway:
return Gateway(name=NAME, capabilities=capabilities, validate=validate, run=run)
2 changes: 1 addition & 1 deletion src/powderline/easydiff/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def _iparm_current(iparm1: dict, key: str, default=0.0):


def check_unsupported(recipe: dict) -> list[str]:
from powderline.topas.symmetry import cell_constraints
from powderline.symmetry import cell_constraints
from powderline.topas.errors import TopasTranslationError

warnings: list[str] = []
Expand Down
33 changes: 14 additions & 19 deletions src/powderline/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@
time — the GSAS-II backend is imported lazily only for ``engine="gsasii"``, so
``powderline.run(engine="topas")`` or ``powderline.run(engine="easydiffraction")``
works on machines without GSAS-II.

Dispatch goes through the gateway registry (``powderline.registry``, entry-point
group ``powderline.gateways``); each old ``engine=`` name maps 1:1 to a registry
name. Each gateway's runtime layer imports its engine lazily.
"""

from __future__ import annotations

from powderline import registry

_ENGINES = ("gsasii", "topas", "easydiffraction")
#: Old ``engine=`` names -> registry names (1:1; the ``engine=`` argument is removed in re/07).
_ENGINE_TO_GATEWAY = {"gsasii": "gsasii", "topas": "topas", "easydiffraction": "easydiffraction"}


def run(
Expand Down Expand Up @@ -42,37 +50,24 @@ def run(

The return dict shape is identical across engines (see ``tests/test_api.py``).
"""
if engine not in _ENGINES:
raise ValueError(f"unknown engine {engine!r}; expected one of {_ENGINES}")
gateway = registry.get(_ENGINE_TO_GATEWAY[engine])
if engine == "gsasii":
from powderline.kicker import run as _gsas_run # lazy: imports GSAS-II

return _gsas_run(
return gateway.run(
recipe,
output_dir,
verbose=verbose,
validate_only=validate_only,
execution_mode=execution_mode,
)
if engine == "topas":
from powderline.topas.engine import run_topas_recipe # GSAS-II-free

return run_topas_recipe(
return gateway.run(
recipe,
output_dir,
verbose=verbose,
validate_only=validate_only,
topas_dir=topas_dir,
topas_version=topas_version,
)
if engine == "easydiffraction":
# GSAS-II-free; lazy import so this dispatcher loads without
# easydiffraction installed. Bind the module (not the function) so tests
# can monkeypatch ``run_easydiffraction_recipe`` on it.
from powderline.easydiff import engine as _easydiff_engine

return _easydiff_engine.run_easydiffraction_recipe(
recipe,
output_dir,
verbose=verbose,
validate_only=validate_only,
)
raise ValueError(f"unknown engine {engine!r}; expected one of {_ENGINES}")
return gateway.run(recipe, output_dir, verbose=verbose, validate_only=validate_only)
105 changes: 105 additions & 0 deletions src/powderline/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""PowderLine exception hierarchy and the structured-warning shape (engine-free).

Every PowderLine-raised error derives from :class:`PowderLineError`. The
gateway errors split along the schema/runtime layers (master plan A23, A5/A8):

- :class:`GatewayNotInstalledError` — no gateway of that name is registered at
all (an out-of-tree plugin that is not installed). The only case where
``validate()`` cannot run.
- :class:`EngineNotAvailableError` — the gateway is present but its engine
dependency is missing from this environment. Raised at ``run()`` time only,
never by ``validate()``/``validate_only``. Also an :class:`ImportError`, so
callers that caught the underlying import failure keep working (A52).
- :class:`EngineVersionError` — the installed engine is outside the gateway's
supported ``ENGINE_VERSION_SPEC``. A hard error (A44).
- :class:`EngineExecutionError` — the engine ran and failed.
- :class:`RecipeValidationError` — a recipe failed validation.

This module must never import an engine (enforced by an import-block test).
"""

from __future__ import annotations

from typing import Optional, TypedDict


class PowderLineError(Exception):
"""Base class for all PowderLine errors."""


class RecipeValidationError(PowderLineError, ValueError):
"""A recipe failed validation.

Wraps (and may annotate) the underlying pydantic errors. ``errors`` holds
pydantic's ``ValidationError.errors()`` list when built from one.
"""

def __init__(self, message: str, *, errors: Optional[list] = None,
schema_name: Optional[str] = None) -> None:
super().__init__(message)
self.errors = list(errors or [])
self.schema_name = schema_name

@classmethod
def from_pydantic(cls, exc, *, schema_name: Optional[str] = None) -> "RecipeValidationError":
"""Build from a ``pydantic.ValidationError``, keeping its error list."""
prefix = f"{schema_name} recipe" if schema_name else "recipe"
err = cls(f"{prefix} failed validation:\n{exc}", errors=exc.errors(),
schema_name=schema_name)
err.__cause__ = exc
return err


class GatewayNotInstalledError(PowderLineError):
"""No gateway of this name is registered (the gateway package is absent)."""

def __init__(self, message: str, *, gateway: str) -> None:
super().__init__(message)
self.gateway = gateway


class EngineNotAvailableError(PowderLineError, ImportError):
"""The gateway is present, but its engine dependency is not installed here."""

def __init__(self, *, gateway: str, dependency: str, env: str,
install_command: str) -> None:
super().__init__(
f"the {gateway!r} gateway needs {dependency}, which is not installed "
f"in this environment; use the {env!r} pixi environment "
f"(install it with: {install_command})"
)
self.gateway = gateway
self.dependency = dependency
self.env = env
self.install_command = install_command


class EngineVersionError(PowderLineError):
"""The installed engine version is outside the gateway's supported range."""

def __init__(self, *, gateway: str, engine: str, installed: str, supported: str) -> None:
super().__init__(
f"the {gateway!r} gateway supports {engine} {supported}, but {installed} "
"is installed; install a supported version"
)
self.gateway = gateway
self.engine = engine
self.installed = installed
self.supported = supported


class EngineExecutionError(PowderLineError):
"""The engine ran and failed."""


class StructuredWarning(TypedDict):
"""One entry of the ``warnings`` list in results and ``validate()`` output.

``field_path`` is the dotted recipe path the warning refers to, or ``None``
when it is not tied to one field. Plumbed into results per gateway in
re/04-06.
"""

code: str
message: str
field_path: Optional[str]
Loading