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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ contributes `d3d`, `toksearch_mast` contributes `mast`.

Most commands need no device selection:

- `fdp env` / `fdp run` compose the environments of **all** installed devices.
Their variables are disjoint, so the union is well-defined. If two devices
ever set the same variable to different values, `fdp` reports the conflicting
variable and asks you to choose.
- `fdp env` / `fdp run` / `fdp chat` / `fdp query` compose the environments of
**all** installed devices. Their variables are disjoint, so the union is
well-defined. If two devices ever set the same variable to different values,
`fdp` reports the conflicting variable and asks you to choose. `chat` and
`query` differ in one way: with no device installed at all they warn and
carry on, since an LLM session that never fetches shot data is still useful.
- `fdp ls` uses the device that has an origin server.
- `fdp login` / `fdp logout` use the device that requires a bearer token.

Expand Down
23 changes: 12 additions & 11 deletions fdp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,20 @@
# limitations under the License.
"""Make the FDP CLI reachable as ``python -m fdp``.

The ``fdp`` console script is not always reachable. Graphviz installs its
force-directed layout engine at the same path -- ``bin/fdp``, one of the eight
engines it ships -- and conda has no conflict detection for that: ``conda-meta``
records graphviz as the owner and graphviz wins. Any environment containing
``cmflib`` pulls graphviz transitively (cmflib -> dvc -> pydot -> graphviz), so
in those environments ``fdp run`` silently invokes a graph layout tool.
Graphviz ships one of its layout engines at ``bin/fdp``, the same path as our
console script, and conda has no conflict detection for that: whichever
package links last owns the file. Anything pulling graphviz in transitively
(cmflib -> dvc -> pydot -> graphviz, i.e. the whole CMF provenance stack)
could therefore replace the FDP CLI with a graph layout tool.

``python -m fdp`` resolves through the installed package rather than ``PATH``,
so it works regardless. Prefer it in scripts and documentation that must run in
environments carrying the provenance stack.
Since 0.6.0 the recipe declares graphviz as a *run dependency* precisely so
link order puts us last and bare ``fdp`` keeps working; a packaged test guards
it. Renaming the console script remains the fallback if that ever stops
holding, and so far it has not been needed.

See ``docs/2026-09-02-fdp-cli-rename.md`` in the workspace for the full
analysis; a rename of the console script is planned for a major release.
``python -m fdp`` resolves through the installed package rather than ``PATH``,
so it cannot be shadowed at all. Prefer it in scripts and documentation that
must run in environments carrying the provenance stack.
"""

import sys
Expand Down
61 changes: 49 additions & 12 deletions fdp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@

from . import auth, config
from .catalog import catalog
from .devices import CAPABILITIES, active_handles, resolve_for_capability
from .devices import (
CAPABILITIES, NoDevicesError, active_handles, resolve_for_capability,
)
from .environment import (
compose_device_config, resolve_bearer_token, setup_environment,
)
Expand All @@ -33,6 +35,12 @@
from .llm_shims import do_query as _llm_do_query
from .skills import BACKENDS, _parse_skill_md, discover_skill_dirs

# The one non-boolean `needs_env` state: attempt setup, but warn and continue
# when no device contributor is installed. Named so a typo is a NameError
# rather than a silent fall-back to strict behaviour (`if needs_env:` treats
# any truthy value as strict).
BEST_EFFORT = "best-effort" # needs_env: True | False | BEST_EFFORT


# ----------------------------------------------------------------------
# Subcommand handlers
Expand Down Expand Up @@ -382,6 +390,7 @@ def build_parser() -> argparse.ArgumentParser:

p_chat = sub.add_parser("chat",
help="Interactive conversational query")
_add_device_arg(p_chat)
_add_llm_args(p_chat)
p_chat.add_argument("--gui", action="store_true",
help="Launch the local Gradio chat GUI "
Expand All @@ -390,16 +399,24 @@ def build_parser() -> argparse.ArgumentParser:
action="store_false", default=True,
help="When --gui is set, do not open a "
"browser tab.")
# chat / query just execvpe into toksearch.llm.cli; no FDP env
# setup needed, and they tolerate no device contributor being
# installed (useful for working inside the fdp dev env).
p_chat.set_defaults(func=do_chat, needs_env=False)
# chat / query want the FDP environment -- the agent fetches shot data --
# but must still run where no device contributor is installed (fdp's own
# dev env). "best-effort" is that middle state: try, warn, continue.
# Setting it up here is safe precisely because these subcommands execvpe
# into a fresh process, so libfdpio and XRootD read the vars at load time.
p_chat.set_defaults(func=do_chat, needs_env=BEST_EFFORT,
auto_login=True)

p_query = sub.add_parser("query", help="One-shot query")
# Unlike `run`, the positional here is a plain one, not REMAINDER, so it
# does not swallow following optionals and the order is free; keep it
# matching `run` anyway.
_add_device_arg(p_query)
p_query.add_argument("query", type=str,
help="Natural-language query (quote it)")
_add_llm_args(p_query)
p_query.set_defaults(func=do_query, needs_env=False)
p_query.set_defaults(func=do_query, needs_env=BEST_EFFORT,
auto_login=True)

p_be = sub.add_parser(
"backends",
Expand All @@ -416,8 +433,12 @@ def main(argv=None) -> None:

# Pure-metadata subcommands (devices, skills, backends) don't touch
# the FDP env and shouldn't require a device contributor to be
# installed, so they opt out via `needs_env=False`.
if getattr(args, "needs_env", True):
# installed, so they opt out via `needs_env=False`. chat/query use
# `needs_env="best-effort"`: they want the env when it is available
# but must not die when it isn't.
needs_env = getattr(args, "needs_env", True)
if needs_env:
best_effort = needs_env == BEST_EFFORT
# Device resolution can fail (e.g. no default chosen among several
# registered tokamaks); present it as a clean message, not a traceback.
try:
Expand All @@ -427,11 +448,27 @@ def main(argv=None) -> None:
auto_login=getattr(args, "auto_login", False),
)
except (ValueError, KeyError) as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
# Only "nothing is installed here" is worth continuing past: it
# is the fdp-dev-env case, and the user asked for a chat, not for
# data. A mistyped --device or a real DeviceEnvConflict (also a
# ValueError) still exits -- degrading there would hand the user
# an agent that silently cannot fetch anything.
no_device_here = best_effort and isinstance(exc, NoDevicesError)
if not no_device_here:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
else:
print("Warning: continuing without the FDP environment. "
f"Data access will not work in this session. ({exc})",
file=sys.stderr)
except auth.AuthError as exc:
print(f"Login failed: {exc}", file=sys.stderr)
sys.exit(1)
if not best_effort:
print(f"Login failed: {exc}", file=sys.stderr)
sys.exit(1)
else:
print("Warning: continuing without a bearer token. Data "
"access will not work in this session; run "
f"`fdp login` to fix it. ({exc})", file=sys.stderr)

try:
args.func(args)
Expand Down
15 changes: 13 additions & 2 deletions fdp/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@
)


class NoDevicesError(ValueError):
"""No tokamak contributor packages are installed.

A ValueError subclass so existing `except ValueError` handlers keep
working; a distinct type so callers that can reasonably continue
without a device -- `fdp chat`/`fdp query` -- can tell this apart
from a mistyped --device or a genuine env conflict.
"""


def _listed(names) -> str:
"""Render device/capability names for a message: ``d3d, devb, mast``.
Interpolating the list itself would leak Python repr punctuation."""
Expand All @@ -71,15 +81,16 @@ def explicit_device_name(device: str | None = None) -> str | None:


def _registered_names() -> list:
"""All registered device names, sorted. Raises if none are installed.
"""All registered device names, sorted. Raises ``NoDevicesError`` if
none are installed.

Every entry point into this module funnels through here first, so
"nothing is installed" always beats "I don't recognize that name" --
the former is the actionable fact.
"""
names = _catalog.names()
if not names:
raise ValueError(_NO_DEVICES)
raise NoDevicesError(_NO_DEVICES)
return names


Expand Down
20 changes: 13 additions & 7 deletions tests/test_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,27 +455,33 @@ def test_empty_env_var_is_treated_as_unset(self):


class TestNoDevicesInstalled(CatalogFixture):
"""A bare fdp env with no contributor packages."""
"""A bare fdp env with no contributor packages.

These assert the *type*, not just the message: `fdp chat`/`fdp query`
degrade to a warning only for NoDevicesError, so a plain ValueError here
would silently turn that degradation back into a hard exit. The message
check alone cannot see the difference.
"""

YAMLS = ()

def test_active_handles_says_install_a_device_package(self):
from fdp.devices import active_handles
with self.assertRaises(ValueError) as ctx:
from fdp.devices import NoDevicesError, active_handles
with self.assertRaises(NoDevicesError) as ctx:
active_handles()
self.assertIn("No tokamak contributors", str(ctx.exception))

def test_explicit_name_still_says_install_a_device_package(self):
# Not "unknown device 'd3d'" — nothing is installed at all, and that
# is the actionable fact.
from fdp.devices import _resolve_device_handle
with self.assertRaises(ValueError) as ctx:
from fdp.devices import NoDevicesError, _resolve_device_handle
with self.assertRaises(NoDevicesError) as ctx:
_resolve_device_handle("d3d")
self.assertIn("No tokamak contributors", str(ctx.exception))

def test_capability_resolution_says_install_a_device_package(self):
from fdp.devices import resolve_for_capability
with self.assertRaises(ValueError) as ctx:
from fdp.devices import NoDevicesError, resolve_for_capability
with self.assertRaises(NoDevicesError) as ctx:
resolve_for_capability("origin", "d3d")
self.assertIn("No tokamak contributors", str(ctx.exception))

Expand Down
Loading
Loading