Wraps the standard Proxmark3 C client (so it works with any Proxmark/firmware) in a prompt_toolkit REPL: full command-tree completion sourced from the client's commands.json, option-flag completion, Tab-to-descend, a press-right help toggle, and the client's own coloured output. Two swappable backends behind ClientDriver: in-process SWIG (_pm3) by default, a pexpect-around-the-binary fallback otherwise. Both auto-detect /dev/ttyACM* and discover the checkout via its doc/commands.json marker (walk-up + side-by-side), so no path is hardcoded. `pm3py client --pack OUT` spins off a self-contained drop-in — a readable .py, or a .pyz with prompt_toolkit/pexpect bundled — amalgamated from the package (the tested source of truth) so it can't drift. Adds pexpect to deps; hardware-free tests in tests/test_clientcli.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
550 lines
21 KiB
Python
550 lines
21 KiB
Python
"""clientcli — command tree, completer, hints, help-toggle, driver helpers, dispatch. Hardware-free
|
|
(the pexpect path is not driven here; a FakeDriver stands in for the client)."""
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from prompt_toolkit.document import Document
|
|
from prompt_toolkit.key_binding import KeyBindings
|
|
|
|
from pm3py.cli.main import build_parser, main
|
|
from pm3py.cli.clientcli.tree import CommandTree, load_command_tree, parse_option
|
|
from pm3py.cli.clientcli.session import ClientSession
|
|
from pm3py.cli.clientcli.completer import ClientCompleter
|
|
from pm3py.cli.clientcli.hints import one_line_hint, help_panel, panel_for, resolve_target
|
|
from pm3py.cli.clientcli.keys import open_help, close_help, install_key_bindings, apply_tab
|
|
from pm3py.cli.clientcli.driver import (
|
|
strip_ansi, _extract_output, PROMPT_RE, resolve_client,
|
|
make_driver, SwigDriver, PexpectDriver, DriverError,
|
|
)
|
|
from pm3py.cli.clientcli.app import dispatch
|
|
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "commands_min.json"
|
|
|
|
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
|
|
|
|
def _plain(s: str) -> str:
|
|
return _ANSI.sub("", s)
|
|
|
|
|
|
def _tree() -> CommandTree:
|
|
return CommandTree(json.loads(FIXTURE.read_text())["commands"])
|
|
|
|
|
|
class FakeDriver:
|
|
"""Records executed lines and returns canned output; stands in for PexpectDriver."""
|
|
|
|
def __init__(self, output="", prompt="offline"):
|
|
self.prompt = prompt
|
|
self.lines = []
|
|
self._output = output
|
|
self.closed = False
|
|
|
|
def start(self):
|
|
pass
|
|
|
|
def execute(self, line):
|
|
self.lines.append(line)
|
|
return self._output
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
def _session(driver=None, tree=None):
|
|
return ClientSession(driver or FakeDriver(), tree or _tree())
|
|
|
|
|
|
def _complete(session, text):
|
|
return list(ClientCompleter(session).get_completions(Document(text), None))
|
|
|
|
|
|
def _texts(comps):
|
|
return [c.text for c in comps]
|
|
|
|
|
|
# --------------------------------------------------------------------------- tree
|
|
|
|
class TestTree:
|
|
def test_top_level_mixes_leaves_and_categories(self):
|
|
toks = [n.token for n in _tree().children_of([])]
|
|
assert toks == ["clear", "hf", "lf"]
|
|
|
|
def test_interior_children_synthesised(self):
|
|
assert [n.token for n in _tree().children_of(["hf"])] == ["14a", "mf"]
|
|
assert [n.token for n in _tree().children_of(["hf", "mf"])] == ["rdbl", "wrbl"]
|
|
|
|
def test_entry_of_leaf(self):
|
|
e = _tree().entry_of(["hf", "mf", "rdbl"])
|
|
assert e is not None and e["description"] == "Read MIFARE Classic block"
|
|
|
|
def test_interior_has_no_entry(self):
|
|
node = _tree().node_at(["hf", "mf"])
|
|
assert node is not None and node.entry is None and not node.is_leaf
|
|
|
|
def test_leaf_flags_as_leaf(self):
|
|
node = _tree().node_at(["clear"])
|
|
assert node.is_leaf and node.children == {}
|
|
|
|
def test_unknown_path(self):
|
|
assert _tree().node_at(["hf", "zz"]) is None
|
|
assert _tree().children_of(["hf", "zz"]) == []
|
|
|
|
|
|
class TestParseOption:
|
|
def test_short_and_long_with_meta(self):
|
|
assert parse_option("-k, --key <hex> key, 6 hex bytes") == (
|
|
["-k", "--key"], "<hex>", "key, 6 hex bytes")
|
|
|
|
def test_long_with_meta(self):
|
|
assert parse_option("--blk <dec> block number") == (["--blk"], "<dec>", "block number")
|
|
|
|
def test_short_no_meta(self):
|
|
assert parse_option("-a input key type is key A (def)") == (
|
|
["-a"], "", "input key type is key A (def)")
|
|
|
|
def test_numeric_short_flag(self):
|
|
assert parse_option("-1 Use data from Graphbuffer (offline mode)") == (
|
|
["-1"], "", "Use data from Graphbuffer (offline mode)")
|
|
|
|
def test_help(self):
|
|
assert parse_option("-h, --help This help") == (["-h", "--help"], "", "This help")
|
|
|
|
|
|
# --------------------------------------------------------------------------- completer
|
|
|
|
class TestCompleter:
|
|
def test_top_level(self):
|
|
assert _texts(_complete(_session(), "")) == ["clear ", "hf ", "lf "]
|
|
|
|
def test_partial_top_level(self):
|
|
assert _texts(_complete(_session(), "h")) == ["hf "]
|
|
|
|
def test_subcommand_partial(self):
|
|
assert _texts(_complete(_session(), "hf m")) == ["mf "]
|
|
|
|
def test_subcommands_after_space(self):
|
|
assert _texts(_complete(_session(), "hf mf ")) == ["rdbl ", "wrbl "]
|
|
|
|
def test_subcommand_completion_has_trailing_space(self):
|
|
# the trailing space makes accepting a completion commit + descend a level
|
|
assert all(t.endswith(" ") for t in _texts(_complete(_session(), "hf mf ")))
|
|
|
|
def test_accepting_subcommand_descends(self):
|
|
# "hf " → accept "14a " → "hf 14a " must offer 14a's children, not re-offer 14a
|
|
assert "14a " in _texts(_complete(_session(), "hf "))
|
|
assert _texts(_complete(_session(), "hf 14a ")) == ["info "]
|
|
|
|
def test_subcommand_display_omits_trailing_space(self):
|
|
disp = {c.display_text: c.text for c in _complete(_session(), "hf mf ")}
|
|
assert "rdbl" in disp and disp["rdbl"] == "rdbl "
|
|
|
|
def test_subcommand_meta_is_description(self):
|
|
comps = {c.text: c.display_meta_text for c in _complete(_session(), "hf mf ")}
|
|
assert comps["rdbl "] == "Read MIFARE Classic block"
|
|
|
|
def test_interior_meta_is_count(self):
|
|
comps = {c.text: c.display_meta_text for c in _complete(_session(), "hf ")}
|
|
assert comps["mf "] == "2 subcommands"
|
|
|
|
def test_option_flags_on_leaf(self):
|
|
texts = _texts(_complete(_session(), "hf mf rdbl --"))
|
|
assert "--blk" in texts and "--key" in texts and "--verbose" in texts
|
|
|
|
def test_option_meta_is_description(self):
|
|
comps = {c.text: c.display_meta_text for c in _complete(_session(), "hf mf rdbl --")}
|
|
assert comps["--blk"] == "block number"
|
|
|
|
def test_help_flag_excluded(self):
|
|
assert "--help" not in _texts(_complete(_session(), "hf mf rdbl --"))
|
|
|
|
def test_leaf_bare_lists_all_options(self):
|
|
texts = _texts(_complete(_session(), "hf mf rdbl "))
|
|
assert "--blk" in texts and "-a" in texts and "-k" in texts
|
|
|
|
def test_argument_value_slot_is_empty(self):
|
|
assert _complete(_session(), "hf mf rdbl -k ") == []
|
|
|
|
|
|
# --------------------------------------------------------------------------- hints
|
|
|
|
class TestHints:
|
|
def test_leaf_one_line(self):
|
|
h = one_line_hint(_tree(), "hf mf rdbl")
|
|
assert "Read MIFARE Classic block" in h and "usage" not in h.lower()
|
|
assert "hf mf rdbl [-habv]" in h
|
|
|
|
def test_interior_one_line(self):
|
|
assert "2 subcommands" in one_line_hint(_tree(), "hf mf")
|
|
|
|
def test_partial_word_resolves_prefix(self):
|
|
assert "2 subcommands" in one_line_hint(_tree(), "hf mf rd")
|
|
|
|
def test_unknown_is_none(self):
|
|
assert one_line_hint(_tree(), "zz") is None
|
|
assert one_line_hint(_tree(), "") is None
|
|
|
|
def test_help_panel_multiline(self):
|
|
panel = _plain(help_panel(_tree().entry_of(["hf", "mf", "rdbl"])))
|
|
assert "hf mf rdbl" in panel
|
|
assert "usage:" in panel
|
|
assert "--blk" in panel and "--key <hex>" in panel
|
|
assert "This help" not in panel # -h/--help is skipped
|
|
assert "hf mf rdbl --blk 0" in panel # a note
|
|
|
|
def test_panel_for_leaf(self):
|
|
node = _tree().node_at(["hf", "mf", "rdbl"])
|
|
assert panel_for(node) == help_panel(node.entry)
|
|
|
|
def test_panel_for_interior(self):
|
|
panel = _plain(panel_for(_tree().node_at(["hf", "mf"])))
|
|
assert "2 subcommands" in panel and "rdbl" in panel and "wrbl" in panel
|
|
|
|
def test_resolve_target_subcommand(self):
|
|
cur = SimpleNamespace(text="rdbl")
|
|
node = resolve_target(_tree(), "hf mf ", cur)
|
|
assert node is not None and node.token == "rdbl" and node.is_leaf
|
|
|
|
def test_resolve_target_flag_points_at_leaf(self):
|
|
cur = SimpleNamespace(text="--blk")
|
|
node = resolve_target(_tree(), "hf mf rdbl --", cur)
|
|
assert node is not None and node.token == "rdbl"
|
|
|
|
def test_resolve_target_interior(self):
|
|
cur = SimpleNamespace(text="mf")
|
|
node = resolve_target(_tree(), "hf ", cur)
|
|
assert node is not None and node.token == "mf" and not node.is_leaf
|
|
|
|
|
|
# --------------------------------------------------------------------------- keys
|
|
|
|
class TestKeys:
|
|
def test_open_help_requires_selection(self):
|
|
s = _session()
|
|
assert open_help(s, has_selection=False) is False
|
|
assert s.help_open is False
|
|
assert open_help(s, has_selection=True) is True
|
|
assert s.help_open is True
|
|
|
|
def test_close_help(self):
|
|
s = _session()
|
|
s.help_open = True
|
|
assert close_help(s) is False
|
|
assert s.help_open is False
|
|
|
|
def test_install_does_not_raise(self):
|
|
install_key_bindings(KeyBindings(), _session())
|
|
|
|
def _buffer_with_menu(self, text):
|
|
from prompt_toolkit.buffer import Buffer, CompletionState
|
|
from prompt_toolkit.completion import CompleteEvent
|
|
from prompt_toolkit.document import Document
|
|
buff = Buffer()
|
|
buff.set_document(Document(text, len(text)))
|
|
comps = list(ClientCompleter(_session()).get_completions(Document(text, len(text)),
|
|
CompleteEvent()))
|
|
buff.complete_state = CompletionState(original_document=buff.document,
|
|
completions=comps, complete_index=None)
|
|
return buff
|
|
|
|
def test_tab_commits_first_completion(self):
|
|
# with nothing highlighted, Tab commits the FIRST completion (+ its trailing space)
|
|
buff = self._buffer_with_menu("hf ")
|
|
apply_tab(buff)
|
|
assert buff.text == "hf 14a " # descends: next Tab would show 14a's kids
|
|
|
|
def test_tab_commits_highlighted_completion(self):
|
|
buff = self._buffer_with_menu("hf ")
|
|
buff.complete_state.complete_index = 1 # highlight the 2nd (mf)
|
|
apply_tab(buff)
|
|
assert buff.text == "hf mf "
|
|
|
|
def test_tab_completes_partial(self):
|
|
buff = self._buffer_with_menu("hf m")
|
|
apply_tab(buff)
|
|
assert buff.text == "hf mf "
|
|
|
|
|
|
# --------------------------------------------------------------------------- driver helpers
|
|
|
|
class TestDriverHelpers:
|
|
def test_strip_ansi(self):
|
|
assert strip_ansi("\x1b[1;32musb\x1b[0m") == "usb"
|
|
|
|
def test_prompt_re_variants(self):
|
|
cases = {
|
|
"[usb] pm3 --> ": "usb",
|
|
"[offline] pm3 --> ": "offline",
|
|
"[usb|tcp] pm3 --> ": "usb|tcp",
|
|
"[fpc|script] pm3 --> ": "fpc|script",
|
|
}
|
|
for text, label in cases.items():
|
|
m = PROMPT_RE.search(text)
|
|
assert m is not None and m.group(1) == label
|
|
|
|
def test_prompt_re_captures_through_ansi(self):
|
|
colored = "[\x1b[1;32musb\x1b[0m] pm3 --> "
|
|
m = PROMPT_RE.search(colored)
|
|
assert m is not None and strip_ansi(m.group(1)) == "usb"
|
|
|
|
def test_extract_output_drops_echo(self):
|
|
raw = "hf 14a info\r\n[+] UID: 04 11 22 33\r\n"
|
|
assert _extract_output(raw, "hf 14a info") == "[+] UID: 04 11 22 33"
|
|
|
|
def test_extract_output_preserves_color(self):
|
|
raw = "hw version\r\n\x1b[32m[+] ok\x1b[0m\r\n"
|
|
assert _extract_output(raw, "hw version") == "\x1b[32m[+] ok\x1b[0m"
|
|
|
|
def test_extract_output_no_echo(self):
|
|
assert _extract_output("just output\r\n", "cmd") == "just output"
|
|
|
|
def test_extract_output_strips_bracketed_paste(self):
|
|
raw = "hw status\x1b[?2004l\r\n\x1b[?2004h[+] ok\r\n"
|
|
assert _extract_output(raw, "hw status") == "[+] ok"
|
|
|
|
def test_resolve_client_explicit_path_verbatim(self):
|
|
assert resolve_client("/opt/pm3/proxmark3") == "/opt/pm3/proxmark3"
|
|
assert resolve_client("./proxmark3") == "./proxmark3"
|
|
|
|
def test_resolve_client_named_binary_not_substituted(self):
|
|
# an explicitly-named client that isn't found is returned unchanged (never swapped for a
|
|
# fallback), so the spawn fails clearly instead of running a different binary
|
|
assert resolve_client("definitely-not-a-real-pm3-binary") == "definitely-not-a-real-pm3-binary"
|
|
|
|
|
|
# --------------------------------------------------------------------------- dispatch
|
|
|
|
class _FakePm3:
|
|
def __init__(self, port):
|
|
self.name = port
|
|
self.calls = []
|
|
|
|
def console(self, cmd, capture=True, quiet=True):
|
|
self.calls.append((cmd, capture, quiet))
|
|
return 0
|
|
|
|
@property
|
|
def grabbed_output(self):
|
|
return ""
|
|
|
|
|
|
class _FakePm3Module:
|
|
pm3 = _FakePm3
|
|
|
|
|
|
class TestSwigDriver:
|
|
def test_execute_prints_via_client_not_grab(self):
|
|
# the client prints its own coloured output (capture=False, quiet=False); execute returns ""
|
|
d = SwigDriver(port="/dev/ttyACM0", pm3_module=_FakePm3Module())
|
|
d.start()
|
|
assert d.backend == "swig" and d.prompt == "usb"
|
|
assert d.execute("hw version") == ""
|
|
assert d._p.calls == [("hw version", False, False)]
|
|
|
|
def test_execute_before_start(self):
|
|
d = SwigDriver(port="/dev/ttyACM0", pm3_module=_FakePm3Module())
|
|
with pytest.raises(DriverError):
|
|
d.execute("hw status")
|
|
|
|
def test_no_device_errors_without_calling_pm3(self, monkeypatch):
|
|
import pm3py.cli.clientcli.driver as drv
|
|
monkeypatch.setattr(drv, "_autodetect_port", lambda: None)
|
|
d = SwigDriver(port=None, pm3_module=_FakePm3Module())
|
|
with pytest.raises(DriverError, match="no serial device"):
|
|
d.start()
|
|
assert d._p is None # never constructed → no exit() risk
|
|
|
|
|
|
class TestPexpectPort:
|
|
def test_autodetects_port_when_none_given(self, monkeypatch):
|
|
# the stock binary needs a port arg (no auto-detect); the driver must resolve one itself
|
|
import pexpect
|
|
import pm3py.cli.clientcli.driver as drv
|
|
|
|
class _FakeChild:
|
|
before = "[usb] "
|
|
def expect(self, pat):
|
|
return 0
|
|
|
|
captured = {}
|
|
def fake_spawn(command, args, **kw):
|
|
captured["command"], captured["args"] = command, args
|
|
return _FakeChild()
|
|
|
|
monkeypatch.setattr(pexpect, "spawn", fake_spawn)
|
|
monkeypatch.setattr(drv, "_autodetect_port", lambda: "/dev/ttyACM9")
|
|
d = drv.PexpectDriver(port=None, client="proxmark3")
|
|
d.start()
|
|
assert captured["args"] == ["/dev/ttyACM9"] # resolved, not empty (would be offline)
|
|
assert d.prompt == "usb" # label parsed from the prompt tail
|
|
|
|
def test_explicit_port_used_verbatim(self, monkeypatch):
|
|
import pexpect
|
|
import pm3py.cli.clientcli.driver as drv
|
|
|
|
class _FakeChild:
|
|
before = "[usb] "
|
|
def expect(self, pat):
|
|
return 0
|
|
|
|
captured = {}
|
|
def fake_spawn(command, args, **kw):
|
|
captured["args"] = args
|
|
return _FakeChild()
|
|
monkeypatch.setattr(pexpect, "spawn", fake_spawn)
|
|
monkeypatch.setattr(drv, "_autodetect_port", lambda: "/dev/ttyACM9")
|
|
drv.PexpectDriver(port="/dev/ttyACM0").start()
|
|
assert captured["args"] == ["/dev/ttyACM0"] # explicit --port wins over auto-detect
|
|
|
|
|
|
class TestMakeDriver:
|
|
def test_explicit_pexpect(self):
|
|
assert isinstance(make_driver(driver="pexpect"), PexpectDriver)
|
|
|
|
def test_auto_uses_swig_when_available(self, monkeypatch):
|
|
import pm3py.cli.clientcli.driver as drv
|
|
monkeypatch.setattr(drv, "_load_pm3_module", lambda: _FakePm3Module())
|
|
d = make_driver(port="/dev/ttyACM0", driver="auto")
|
|
assert isinstance(d, SwigDriver)
|
|
|
|
def test_auto_falls_back_to_pexpect(self, monkeypatch):
|
|
import pm3py.cli.clientcli.driver as drv
|
|
def boom():
|
|
raise ImportError("no _pm3")
|
|
monkeypatch.setattr(drv, "_load_pm3_module", boom)
|
|
assert isinstance(make_driver(driver="auto"), PexpectDriver)
|
|
|
|
def test_swig_explicit_unavailable_errors(self, monkeypatch):
|
|
import pm3py.cli.clientcli.driver as drv
|
|
def boom():
|
|
raise ImportError("no _pm3")
|
|
monkeypatch.setattr(drv, "_load_pm3_module", boom)
|
|
with pytest.raises(DriverError, match="_pm3"):
|
|
make_driver(driver="swig")
|
|
|
|
|
|
class TestDispatch:
|
|
def test_runs_command(self):
|
|
drv = FakeDriver(output="[+] done")
|
|
out = []
|
|
assert dispatch(_session(drv), "hf 14a info", out.append) is True
|
|
assert drv.lines == ["hf 14a info"]
|
|
assert out == ["[+] done"]
|
|
|
|
def test_quit_exits(self):
|
|
drv = FakeDriver()
|
|
assert dispatch(_session(drv), "quit", lambda *_: None) is False
|
|
assert drv.lines == []
|
|
|
|
def test_empty_output_not_printed(self):
|
|
out = []
|
|
dispatch(_session(FakeDriver(output="")), "clear", out.append)
|
|
assert out == []
|
|
|
|
def test_driver_error_ends_loop(self):
|
|
from pm3py.cli.clientcli.driver import DriverError
|
|
|
|
class Boom(FakeDriver):
|
|
def execute(self, line):
|
|
raise DriverError("client exited")
|
|
|
|
out = []
|
|
assert dispatch(_session(Boom()), "hw status", out.append) is False
|
|
assert any("client exited" in m for m in out)
|
|
|
|
|
|
# --------------------------------------------------------------------------- session + argparse + loader
|
|
|
|
class TestSession:
|
|
def test_breadcrumb_offline(self):
|
|
s = _session(FakeDriver(prompt="offline"))
|
|
assert s.breadcrumb() == "[client / offline] pm3py -->"
|
|
assert _plain(s.colored_breadcrumb()) == s.breadcrumb()
|
|
|
|
def test_breadcrumb_usb(self):
|
|
assert _session(FakeDriver(prompt="usb")).breadcrumb() == "[client / usb] pm3py -->"
|
|
|
|
|
|
class TestArgparse:
|
|
def test_client_subcommand(self):
|
|
args = build_parser().parse_args(["client", "--port", "X", "--client", "pm3"])
|
|
assert args.command == "client" and args.port == "X" and args.client == "pm3"
|
|
assert args.driver == "auto" # SWIG-first default
|
|
|
|
def test_client_driver_choice(self):
|
|
args = build_parser().parse_args(["client", "--driver", "pexpect"])
|
|
assert args.driver == "pexpect"
|
|
|
|
def test_no_subcommand_prints_help(self):
|
|
assert main([]) == 1
|
|
|
|
|
|
class TestLoader:
|
|
def test_loads_fixture(self):
|
|
tree, meta = load_command_tree(str(FIXTURE))
|
|
assert meta["commands_extracted"] == 5
|
|
assert tree.entry_of(["hf", "mf", "rdbl"])["command"] == "hf mf rdbl"
|
|
|
|
def test_missing_raises(self):
|
|
with pytest.raises(FileNotFoundError):
|
|
load_command_tree("/nonexistent/commands.json")
|
|
|
|
|
|
class TestRepoDiscovery:
|
|
def _make_repo(self, base):
|
|
repo = base / "proxmark3"
|
|
(repo / "doc").mkdir(parents=True)
|
|
(repo / "doc" / "commands.json").write_text('{"commands": {}}')
|
|
return repo
|
|
|
|
def test_via_env(self, tmp_path, monkeypatch):
|
|
from pm3py.cli.clientcli.tree import find_repo_root
|
|
repo = self._make_repo(tmp_path)
|
|
monkeypatch.setenv("PM3_REPO", str(repo))
|
|
assert find_repo_root() == repo.resolve()
|
|
|
|
def test_walks_up_from_subdir(self, tmp_path, monkeypatch):
|
|
from pm3py.cli.clientcli.tree import find_repo_root
|
|
monkeypatch.delenv("PM3_REPO", raising=False)
|
|
monkeypatch.delenv("PM3_ROOT", raising=False)
|
|
repo = self._make_repo(tmp_path)
|
|
deep = repo / "client" / "src"
|
|
deep.mkdir(parents=True)
|
|
assert find_repo_root(start=deep) == repo.resolve()
|
|
|
|
def test_finds_sibling_checkout(self, tmp_path, monkeypatch):
|
|
# dev layout: run from a sibling dir, discover ../proxmark3 (keeps `pm3py client` working)
|
|
from pm3py.cli.clientcli.tree import find_repo_root
|
|
monkeypatch.delenv("PM3_REPO", raising=False)
|
|
monkeypatch.delenv("PM3_ROOT", raising=False)
|
|
repo = self._make_repo(tmp_path)
|
|
sibling = tmp_path / "pm3py"
|
|
sibling.mkdir()
|
|
assert find_repo_root(start=sibling) == repo.resolve()
|
|
|
|
|
|
class TestPacker:
|
|
def test_amalgamate_is_self_contained(self):
|
|
from pm3py.cli.clientcli.pack import amalgamate
|
|
code = amalgamate()
|
|
compile(code, "pm3cli", "exec") # valid Python
|
|
assert code.count("from __future__") == 1 # exactly one, at the top
|
|
for ln in code.splitlines():
|
|
s = ln.lstrip()
|
|
assert not s.startswith("from ."), ln # no intra-package imports survive
|
|
assert not s.startswith(("from pm3py", "import pm3py")), ln
|
|
assert "def main(" in code and 'if __name__ == "__main__"' in code
|
|
assert "def run(" in code # the REPL entrypoint is present
|
|
|
|
def test_pack_writes_executable_py(self, tmp_path):
|
|
from pm3py.cli.clientcli.pack import pack
|
|
out = tmp_path / "pm3cli.py"
|
|
pack(str(out))
|
|
assert out.is_file() and os.access(out, os.X_OK)
|
|
compile(out.read_text(), str(out), "exec")
|