Skip to content

Split cli.py into a command package - #19

Merged
muellerei merged 25 commits into
mainfrom
refactor/split-cli-module
Sep 16, 2026
Merged

muellerei merged 25 commits into
mainfrom
refactor/split-cli-module

Conversation

@muellerei

Copy link
Copy Markdown
Owner

Split cli.py into a command package

What changed

cli.py held every command in 5390 lines. The commands now live in
logseq_cli/commands/, one module per group, with three modules beside them:

  • group.py — the click group, the global options, and the only place the API
    client is constructed
  • output.py — results on stdout, failures on stderr, --json
  • render.py — blocks to text, and resolving block references

cli.py is the entry point that imports them: 33 lines. A command exists once
its module has been imported, and that list is written out rather than
discovered by scanning — docs/adr/0001-explicit-command-registration.md says
why, and records the two alternatives that were rejected.

What did not change

The console entry point, every command name, every option, every default and
every help text. The per-command --help output of all 38 command names was
captured before the first commit and diffed against after each of the 22, and
it never differed once.

How it is verifiable

Nineteen of the 22 commits are text moves with the suite green at each, so the
series bisects. Four mid-series commits were checked out and run to confirm
that rather than assume it.

Three commits are not moves and are separated on purpose:

  • nine helpers read from more than one module lose their leading underscore, in
    a commit where nothing else happens
  • handle_connection_error builds its wrapper with functools.wraps
  • the scan behind the --dry-run guarantee is parsed with ast instead of
    split on top-level def

The last two are the interesting ones. The --dry-run contract covers 18
commands, and the scan that enforces it found them by reading cli.py as text.
Measured in a full simulation of the target layout before any of this was
committed: without functools.wraps, that scan finds 0 writers instead of
18
— and reports it by passing.

Added

  • tests/test_command_registry.py asserts the full set of 38 command names. A
    command module nobody imports registers nothing and the CLI still starts;
    the README counter test does notice, but it reports a sum, not a name, and it
    stays green when the delete-block alias is the thing that went missing.
    Both cases probed.
  • A CI job comparing the built wheel against the source tree. pyproject.toml
    lists packages explicitly and setuptools does not infer a subpackage from an
    explicit list, so a release could have shipped a CLI that installs, starts,
    and has no commands. pip install -e . cannot see this.

Three guarantees that nothing was asserting

Found by mutation testing at the end of this work — change one thing, run the
whole suite, see whether anything goes red. Each of the three is a rule this
split introduces, so none of them could have been broken before it.

  • functools.wraps: at the commit that adds it, taking it back out again
    left all 833 tests green. It only becomes checkable one commit later, through
    a test about --dry-run coverage that reports the symptom rather than the
    cause. Asserted directly now.
  • The import list: dropping a command module from cli.py left the suite
    green for four of the nine. Other test files import commands.blocks,
    .properties, .analysis and .pages directly to reach a helper, and
    importing a command module registers its commands as a side effect — so the
    registry had been filled by somebody else before the guard looked at it. The
    import list is now read from the source, which does not depend on what ran
    first. All nine removals fail with it.
  • ADR 0001's import rules: a command module reaching sideways for a
    neighbour's helper, and a non-empty commands/__init__.py, both passed. They
    were checked only by local/specs/audit-001-map.py, which parses a spec that
    is now archived and runs in no CI job. Four assertions in
    tests/test_package_layering.py instead, each probed by breaking it.

Checks

  • suite: 842 (832 before, plus the registry test and the three guards above),
    green at every commit
  • both help baselines: empty diff
  • no test reaches into logseq_cli.cli for anything but the group object
  • LogseqAPI( appears on exactly one line, in group.py
  • wheel: 19 modules in the artefact, 19 in source; installed into a clean venv
    outside the source tree it reports 38 commands and the right version

CHANGELOG

Two entries under Unreleased: the split under Changed, and the packaging line
under Fixed — worded as shipping the subpackage rather than repairing a
defect, because the list was correct while the package was flat and no release
was affected.

The import list in cli.py will look like a formality to anyone who reads it,
and "why not scan the directory?" is the first idea a reader has when they see
nine import lines. Both alternatives were considered and rejected for reasons
that are not visible from the code itself.

The working spec that weighs them is not part of this repository and is
archived once the work is done, so the reasoning goes here instead.
Spec 001 moves every command into nine modules that cli.py imports by name.
A module left out of that list registers nothing, and the CLI still starts —
it is simply missing commands. The same holds for a second Command Name left
behind when its Command moves to another module.

The README counter test does go red on a missing module, but it reports a
sum, not a name, and it excludes Command Names that are not canonical: with
`delete-block` unregistered it stays green. Measured both ways before writing
this: dropping the alias registration leaves the README tests passing and
makes this one name `delete-block` in its failure.

The set is literal rather than derived. Iterating cli.commands would assert
that the registry equals itself, and a module that is never imported leaves
nothing to iterate over; a count holds until something is added in the same
commit and then does not say what went missing.
The wrapper copied __name__ and __doc__ by hand. That is enough for the help
text, and not enough for anything that asks where a callback came from:
__module__ stays that of the module defining the decorator, and __wrapped__
is never set.

Today decorator and commands share one file, so the two cannot be told apart.
Spec 001 moves the decorator to output.py and the commands to nine modules,
and tests/test_dry_run_coverage.py unwraps each callback and parses the module
__module__ names to decide which commands write. Measured in a full simulation
of that layout: without wraps the scan reports 0 writing commands instead of
18, silently, because every callback claims to live in the decorator's module.

Probe, red before and green after: decorate a function defined in __main__ and
read back __module__ and __wrapped__.

Click does not inspect callback signatures, so the CLI is unchanged — both
help baselines diff empty.
The scan that decides which commands write read cli.py as text and cut each
body at the next top-level def. That puts the decorator lines of the following
function at the end of the previous body: 73 of them carry foreign trailing
text today. It changes nothing right now — regex and ast agree on the same 18
writers — but it holds only while the neighbours stay put, and spec 001 moves
every command into one of nine modules.

The failure would have been silent. The file's own guard checked five example
names, so a scan that found fewer commands than it should would still pass,
and --dry-run coverage across 18 commands would go unchecked.

So: parse with ast, per module, keyed on the callback's __module__ after
unwrapping; and assert the full set of writers as an equality, so a scan that
shrinks and an inventory that shrinks both fail.

Both directions probed and restored: removing --dry-run from create-page names
it in the failure, and dropping a name from the known set fails too.
The test reads every module except api.py into `code` and then asserts only
against `api_src`. Verified with ast: the function stores `code` and never
loads it. It came in with a5f2c19, which removed a cacheable method nobody
called; the scan looks like it was meant to check call sites and then was not.

Removed rather than repaired. Making it recursive and asserting against it
would turn a dead line into a new assurance, which belongs with the
_MUTATING_METHODS work the roadmap files alongside spec 010.

Left as it is, it would get worse: src.glob("*.py") is not recursive, so once
spec 001 moves the commands into logseq_cli/commands/ the line would look like
it covered them while covering nothing.
First extraction of spec 001. handle_connection_error, output and fail go
across unchanged; cli.py imports them back, so nothing that calls them moves
yet.

The imports the new module needs were read off the moved code with ast rather
than guessed: click, requests, functools, json, sys, DatalogQueryError,
ConfigError, InvalidKeywordError. Three of those then had no reader left in
cli.py and are removed there.

Three imports in cli.py are unused and stay: block_uuid_from_result,
escape_regex and has_flush_newline_bullets were already dead before this
commit and are not this commit's to clean up.

Suite unchanged at 833, both help baselines diff empty, audit script exit 0.
Spec 001 gives render.py, group.py and nine command modules their own files.
These nine names are read from outside the module that defines them, and a
private name imported from three other modules is not private — the underscore
would misdescribe it.

Nothing moves here. A rename and a move in one commit is two changes with one
diff that shows neither: git log --follow and blame lose the thread, and a
reviewer cannot tell a renamed line from a moved one. Keeping them apart is
what lets every later commit in the series be a pure move.

_resolve_single_ref keeps its underscore: only resolve_refs_in_blocks calls it,
inside the same module.

tests/test_version.py follows the rename in this commit because it imports the
name; its module changes in the step that moves resolve_version to group.py.
Nine symbols, unchanged: the two reference resolvers, the four that render
blocks to text or Markdown, the section extractor, the properties predicate
and BLOCK_REF_RE. cli.py imports them back, so no caller moves yet.

pages, journal and todos all read these, which is why they get their own
module rather than living with whichever command module happens to use them
most. _resolve_single_ref comes along and keeps its underscore — it is called
only from resolve_refs_in_blocks, in the same file.

The module needs exactly two imports, read off the moved code rather than
guessed: re, and normalize_heading from helpers.

Suite 833, both help baselines diff empty, audit script exit 0.
resolve_version and the group callback go across unchanged. cli.py imports
both back, so it still exposes `cli` and nothing that decorates against the
group has to move yet.

This is the commit that moves the API constructor out of cli.py, so the suite
follows in the same commit: 220 occurrences of logseq_cli.cli.LogseqAPI across
38 files become logseq_cli.group.LogseqAPI. Counted before and after — a sed
over 38 files reports nothing on its own — and both counts are 220 with no
occurrence of the old target left. mock.patch replaces a name in the namespace
it is given, so a patch left on cli.py would have replaced a re-exported name
nobody calls and built a real client against a mock server.

Probed rather than assumed: pointing one file's patches back at
logseq_cli.cli turns 8 of its 12 tests red, so the new target is the one doing
the work.

tests/test_version.py changes module here (its rename happened in the previous
commit): resolve_version now comes from logseq_cli.group. Left on cli.py it
would have stayed green until the meta.py commit and then failed with an
ImportError at a place no table names.

resolve_version stays one level below the repository root because it finds
pyproject.toml relative to its own file; group.py is that level.

Suite 833, both help baselines diff empty, audit exit 0.
First of the nine command modules, and the one that creates the subpackage —
smallest and most isolated first, so the pattern is proven before the large
ones. Registration happens as a side effect of the import in cli.py; the
commands themselves are unchanged.

The packaging line belongs in this commit, not after the series: setuptools
does not infer subpackages from an explicit `packages` list, so from here on
every wheel without it would ship a CLI that installs, starts, and has no
commands. `pip install -e .` hides this completely, and so does `pip wheel`
reading a cached build — measured. Checked with --no-cache-dir against the
source tree: 11 modules in the wheel, 11 in source.

tests/test_find_block_children.py imports FIND_BLOCK_CHILDREN_LIMIT and moves
with it. Its import is the combined form, so the whole line is replaced rather
than the symbol.

Suite 833, both help baselines diff empty, audit exit 0.
Six symbols: the two commands, the marker table and the three helpers that
only they use. set-todo-status matches block references, so this module
imports BLOCK_REF_RE from render.py — one of the two cross-module edges the
map records, and the reason that regex is not private to either side.

No test pointer moves with it.

Suite 833, both help baselines diff empty, audit exit 0.
Five commands and the four helpers only they use.

tests/test_property_list_values.py imports _format_property_value and moves
with it; its import is the combined form, so the whole line is replaced.

Suite 833, both help baselines diff empty, audit exit 0.
The command and the two helpers only it uses. No test pointer moves with it.

Suite 833, both help baselines diff empty, audit exit 0.
Twelve symbols: the two commands, the config renderer and the helpers only
they use. doctor reports the version, so this module imports resolve_version
from group.py — the second of the two cross-module edges the map records.

Three test pointers move with it. Two are dotted patch targets; the third is
tests/test_doctor.py taking the module object itself to replace import_module.

That third one does not fail the way the spec predicted. Pointing it back at
logseq_cli.cli does not raise AttributeError — cli.py still imports
import_module for its own use, so monkeypatch.setattr succeeds and patches a
name doctor no longer reads. The test fails on its assertion instead, which is
loud enough here only because it asserts the patched behaviour directly.

Suite 833, both help baselines diff empty, audit exit 0.
Four commands and the four helpers only they use.

Four test pointers move with it, two of each kind. The imports of
_project_pattern, _word_pattern and _is_incidental_page fail loudly if left
behind; the two get_page_content patches do not — the helper is defined in
helpers.py and read here, so a patch left on logseq_cli.cli would replace a
name nobody calls and let the real helper run against the mock API.

Patching it at its definition does not work either, and an earlier revision of
the spec prescribed exactly that: pointing this file at
logseq_cli.helpers.get_page_content turns 7 of its 13 tests red. mock.patch
replaces a name in the namespace it is given, and the namespace that reads it
is this module.

tests/test_config_integration.py used the combined import form and takes `cli`
from logseq_cli.cli, not from the command module — a test takes the group from
the entry point, so that the registry it sees is the full one.

Suite 833, both help baselines diff empty, audit exit 0.
Nine commands and the backlink-context helper.

tests/test_backlinks_context.py imports that helper and moves with it. It is
the fifth file using the combined import form, and the second where the two
names are the other way round, so the whole line is replaced rather than the
symbol.

Suite 833, both help baselines diff empty, audit exit 0.
Seven commands, and the alias registration that sits outside every map entry:
delete-block is added with cli.add_command a few lines below remove-block
rather than by decorator, so it travels with the command it names or it stays
behind referring to a symbol cli.py no longer defines.

Probed here rather than trusted: commenting out that one line makes the
registry guard from the start of this series fail naming delete-block.

Suite 833, both help baselines diff empty, audit exit 0.
Five commands, the last of the nine modules.

The two remaining get_page_content patches move with it, for the same reason
as the analysis ones: the name is read here, and a patch left on cli.py would
have replaced something nobody calls without turning anything red.

With this commit the test migration is complete: no test reaches into
logseq_cli.cli for anything but the group object, on any of the three forms
the acceptance rule greps for.

Suite 833, both help baselines diff empty, audit exit 0.
Everything has moved, so what is left is the import list that makes the
commands exist and the main() the console script points at: 5390 lines down to
33. The 70-odd imports the file still carried were read by nothing.

The largest file in the package is now commands/journal.py at 820 lines, and
logseq_cli/ holds nine command modules plus group, output and render.

Wheel checked against the source tree with --no-cache-dir: 19 modules in both.
Suite 833, both help baselines diff empty, audit exit 0.
pyproject.toml lists packages explicitly, and setuptools does not infer
subpackages from an explicit list. So logseq_cli/commands could be left out of
a release without anything going red: the tests run against an editable
install, which links the source tree and cannot see the difference. What
reaches a user is a CLI that installs, starts, and has no commands.

The job compares the wheel against the source tree rather than looking for
logseq_cli/commands by name, so a later subpackage is covered without touching
this file.

Two flags earn their place, both measured while writing this. Without
--no-cache-dir, pip serves a cached build and the check inspects a wheel it did
not build. With a stale logseq_cli.egg-info in the tree, setuptools reuses its
SOURCES.txt and ships the subpackage even after `packages` was narrowed — so
the clean step comes first. With both in place, reverting the packages line
fails the check and names all ten missing modules.
Both structure trees named four modules and one cli.py "with all commands".
They now list group, output, render and the nine command modules — and
config.py, which was missing from both before this work started.

CONTRIBUTING told a reader to start in cli.py, "over five thousand lines,
which is more than one file should carry and is being split". That file is 33
lines now, so the sentence points at the wrong place twice. It is replaced by
what a contributor actually needs: which module a command lives in, that a new
module has to be named in the import list or its commands do not exist, and
which test says so by name when it is forgotten.

Neither tree carries a command count or a line number. Both would drift, and
the registry is the authority anyway.

Re-checked against the built state rather than assumed: the ADR's three
claims (empty commands/__init__.py, group.py importing nothing from commands/,
cli.py naming all nine modules) hold; CONTEXT.md makes no claim about file
structure; 38 command names over 37 callbacks; every --page option still has
its --name alias; check-links reports 8 files, 0 broken links; suite 833.
Two entries. The Changed one says what a user of the package needs: nothing
about the CLI changes, and the two parts of the work that are not pure text
moves are named rather than folded into "refactoring".

The Fixed one is worded for what actually happened. `packages = ["logseq_cli"]`
was correct for a flat package and only became wrong once there was a
subpackage, which landed in the same commit as the fix — so it ships something
new rather than repairing something that used to fail. No release was affected.

The CI job that checks this gets no entry: it changes nothing a user can
observe.
The wrapper was given functools.wraps earlier in this series because the
--dry-run scan unwraps each command callback and parses the module __module__
names. Nothing asserted that property: at the commit that introduced it, taking
functools.wraps back out again left all 833 tests green. It only becomes
checkable one commit later, through a test about --dry-run coverage.

That test does notice, but it cannot say why. It reports that create-page and
sixteen others write without offering --dry-run, which reads as a defect in the
commands rather than in the decorator two files away.

Four assertions instead: the wrapper keeps the wrapped function's module, it
exposes __wrapped__, it still carries name and docstring, and every callback in
the registry unwraps to its own module rather than to logseq_cli.output.

Probed by removing functools.wraps again: three of the four go red and name the
cause. The fourth stays green, correctly — the hand-built wrapper copied
__name__ and __doc__ all along, and that was exactly what made the gap look
like it was covered.
The registry assertion in this file is supposed to catch a command module that
cli.py stops importing. Run on its own it does. In the full suite it does not,
for four of the nine: test_find_block_children.py, test_property_list_values.py,
test_config_integration.py and test_backlinks_context.py import
logseq_cli.commands.blocks, .properties, .analysis and .pages directly to reach
a helper, and importing a command module registers its commands as a side
effect. By the time the registry is asserted, another file has filled it.

Measured by dropping each of the nine from the import list in turn: blocks,
properties, analysis and pages left all 837 tests green. The other five failed
loudly, but only because their commands were exercised elsewhere — nothing was
asserting the import list.

So assert it: parse cli.py and compare the names it imports against the modules
on disk. That holds regardless of what any other test imported first. With it,
all nine removals fail.
ADR 0001 states three rules the split rests on: a command module decorates
against the group, commands/__init__.py stays empty, and group.py imports
nothing from commands/. Until now only one of them failed on its own — the
cycle, loudly. The other two were quiet: a command module reaching sideways for
a neighbour's helper works until two of them reach for each other, and an
__init__.py that imports a module makes it register whether or not cli.py names
it, which turns the explicit import list into decoration and hides the very
omission the registry test exists to catch.

Both were checked during the split by local/specs/audit-001-map.py. That script
parses the module map out of a specification that is now archived, and it runs
in no test and no CI job. A rule enforced only by a tool nobody runs is a rule
on paper.

Four assertions against the source, plus the layering rule the ADR implies:
nothing below the command layer imports from commands/. Each probed by breaking
it — sideways import, non-empty __init__, group.py reaching down, render.py
reaching up. All four go red; the cycle as a collection error, which is louder
still.
@muellerei
muellerei merged commit c87698c into main Sep 16, 2026
5 checks passed
@muellerei
muellerei deleted the refactor/split-cli-module branch September 16, 2026 23:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant