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
40 changes: 40 additions & 0 deletions fdp/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright 2026 General Atomics
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# 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.

``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.

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.
"""

import sys

from fdp.cli import main

if __name__ == "__main__":
# argparse derives prog from basename(sys.argv[0]), which is
# "__main__.py" under -m. Usage and error messages should show something
# the reader can actually type.
sys.argv[0] = "python -m fdp"
main()
62 changes: 62 additions & 0 deletions recipe/recipe.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,41 @@ requirements:
- pelicanplatform >=7.24.3,<8
- fdp-schema >=0.2.1
- toksearch
# DELIBERATE AND LOAD-BEARING -- do not remove as an unused dependency.
#
# graphviz ships `bin/fdp` (a symlink to `dot`, dispatching on argv[0])
# and so do we. Anything that pulls graphviz into an FDP environment --
# cmflib -> dvc -> pydot -> graphviz, which is the whole CMF provenance
# stack -- lands a second owner on `$PREFIX/bin/fdp`. Whichever package
# is linked last wins the file, and without this dependency graphviz can
# win: `fdp run python ...` then silently becomes a graph layout tool and
# the FDP CLI is simply gone. There is no warning to print, because our
# entry point is the thing that got replaced.
#
# Declaring graphviz here makes it our *dependency*, so the installer's
# topological link order puts graphviz first and `fdp` last, and we take
# the file.
#
# Which installer you use decides whether this is a fix or an insurance
# policy, and both are worth having:
#
# pixi/rattler -- graphviz genuinely wins without this dependency; the
# CLI is broken today. With it we win from scratch (3/3 runs), on an
# incremental `pixi add cmflib`, and across a real graphviz
# 13.1.2 -> 14.1.2 upgrade. That last case means rattler respects
# existing file ownership, not merely link order.
#
# micromamba 2.9.0 -- happens to link `fdp` last even without this
# dependency (graphviz at step 1193, fdp at 1266), so it already
# worked. But nothing *enforced* that order; it was incidental to the
# solve. This dependency makes it guaranteed rather than lucky.
#
# Nothing is lost on the graphviz side: `dot -Kfdp` is the documented
# equivalent of its `fdp` binary.
#
# The test below is the guard. If it ever fails, this trick has stopped
# working and the CLI needs renaming instead.
- graphviz

tests:
- script:
Expand All @@ -45,6 +80,33 @@ tests:
source:
- tests/

# Guard for the graphviz `bin/fdp` collision explained in the run
# requirements. graphviz is a run dependency, so this test environment
# reproduces the collision for free -- if `fdp` here is graphviz's binary
# rather than ours, the CLI is broken for every user with cmflib installed.
- script:
content:
- |
set -e
echo "bin/fdp resolves to: $(readlink -f "$(command -v fdp)")"
# graphviz's fdp answers -V with "fdp - graphviz version ...".
if fdp -V 2>&1 | grep -qi "graphviz"; then
echo "ERROR: bin/fdp is graphviz's binary, not the FDP CLI."
echo "The link-order trick (graphviz as a run dep) has stopped"
echo "working. The CLI must be renamed instead. See the comment"
echo "beside the graphviz run dependency in recipe.yaml."
exit 1
fi
# Ours must actually work, not merely be present.
fdp --help | grep -qE "\brun\b" || {
echo "ERROR: 'fdp --help' does not look like the FDP CLI:"; fdp --help | head -5; exit 1; }
echo "OK: bin/fdp is the FDP CLI."
# The unclobberable fallback: even if bin/fdp is ever lost, this
# entry point cannot be taken by another package.
- |
python -m fdp --help > /dev/null
echo "OK: python -m fdp works"

about:
homepage: https://github.com/GA-FDP/fdp
license: Apache-2.0
Expand Down
60 changes: 60 additions & 0 deletions tests/test_module_entry_point.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Copyright 2026 General Atomics
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""`python -m fdp` must stay reachable.

The `fdp` console script is not always reachable: graphviz installs its
force-directed layout engine at the same `bin/fdp` path, conda records graphviz
as the owner, and graphviz wins. Any environment with cmflib pulls graphviz
transitively, so `fdp run` there invokes a layout tool. `python -m fdp`
resolves through the package rather than PATH.
"""

import subprocess
import sys
import unittest


class TestModuleEntryPoint(unittest.TestCase):
def _run(self, *args):
return subprocess.run(
[sys.executable, "-m", "fdp", *args],
capture_output=True, text=True, timeout=60,
)

def test_module_invocation_works(self):
self.assertEqual(self._run("--help").returncode, 0)

def test_every_subcommand_is_reachable(self):
out = self._run("--help").stdout
for command in ("run", "env", "login", "logout", "ls", "catalog",
"device", "skills"):
self.assertIn(command, out)

def test_usage_shows_something_typeable(self):
# argparse defaults prog to basename(sys.argv[0]), which is
# "__main__.py" under -m -- useless in an error message.
out = self._run("--help").stdout
self.assertIn("python -m fdp", out)
self.assertNotIn("__main__.py", out)

def test_errors_name_the_module_form(self):
err = self._run("nosuchcommand").stderr
self.assertIn("python -m fdp", err)

def test_it_is_the_same_cli(self):
from fdp.cli import main

import fdp.__main__ as entry

self.assertIs(entry.main, main)
Loading