Skip to content

feat(ext.argparse): add read-only _command_meta accessor (#670)#787

Open
derks wants to merge 6 commits into
mainfrom
gsd/phase-05.2-ext-argparse-command-self-meta-accessor-670-pr
Open

feat(ext.argparse): add read-only _command_meta accessor (#670)#787
derks wants to merge 6 commits into
mainfrom
gsd/phase-05.2-ext-argparse-command-self-meta-accessor-670-pr

Conversation

@derks

@derks derks commented Jun 26, 2026

Copy link
Copy Markdown
Member

Summary

Adds a single additive, read-only _command_meta property to
ArgparseController, resolving #670.
An exposed @ex/@expose command can now read its own CommandMeta from
inside its body via self._command_meta — e.g.
self._command_meta.parser_options['help'] — instead of the brittle
getattr(self, self.app.pargs.__dispatch__.split('.')[1]).__cement_meta__
dance (which broke in the wild once CommandMeta became a dataclass).

The change is purely additive and backward-compatible on the 3.0.x track:
the released func() dispatch signature is untouched. The issue's literal
func(func_name, func_meta) injection proposal is intentionally not adopted
here — it would change the released dispatch signature and is deferred to
Cement 4 (recorded as a # FIXME: near the accessor and at _dispatch).

Changes

Accessor (feat)

  • New ArgparseController._command_meta @property returning
    CommandMeta | None. Derives the running command from
    self.app.pargs.__dispatch__, resolves the class-level CommandMeta via the
    function's __cement_meta__ marker, and returns None outside a dispatched
    command. Never raises.

Robustness hardening (fix — from code review)

  • The guard keys on the __cement_meta__ marker
    (getattr(member, '__cement_meta__', None)) rather than bare attribute
    existence. This makes the documented "never raises" contract provably hold
    even when the property is read from a non-owning controller (e.g. via
    _post_argument_parsing) whose class happens to carry a same-named,
    non-command attribute — that path now returns None instead of raising
    AttributeError. The change also collapses both guard branches and removes a
    # pragma: nocover defensive line.

Tests

  • test_command_meta_dispatched — command reads its own @expose(help=...).
  • test_command_meta_none_outside_dispatch — no-dispatch path returns None.
  • test_command_meta_non_owning_controller_returns_none — non-owning
    controller returns None (never raises).

Docs

  • CHANGELOG.md: [ext.argparse] Features entry referencing Push function name and meta of sub-command into function #670.
  • CLAUDE.md: adds a Branching Policy section — never commit to main
    without explicit consent (planning artifacts included), and branch
    immediately when phase/task work begins rather than relying on GSD's
    branching_strategy config.

Acceptance status

Decision Description Status
D-01 Read-only _command_meta property on ArgparseController
D-02 Returns CommandMeta for the running command from pargs.__dispatch__
D-03 Returns None (never raises) when no command is dispatched
D-04 Returns raw CommandMeta as-is (no wrapper/shortcuts)
D-05 Exposes only the currently-running command's meta
D-06 func() dispatch signature unchanged (no signature injection)
D-07 Cement 4 deferral comment near accessor and at _dispatch

Verified 5/5 must-have truths against the codebase (full report archived
post-merge with the phase planning artifacts).

Files touched

File Change
cement/ext/ext_argparse.py New _command_meta property (additive, read-only)
tests/ext/test_ext_argparse.py 3 tests: dispatched, None, non-owning-controller
CHANGELOG.md [ext.argparse] Features entry (#670)
CLAUDE.md New Branching Policy section

Test plan

  • make comply — ruff clean + mypy clean (51 source files)
  • make test — full suite 375 passed, 100.00% coverage
    (Redis + memcached up); ext_argparse.py 318/318 stmts, 0 miss
  • Backward compatibility — single return func() at dispatch is unchanged;
    every existing def cmd(self) still receives zero extra positional args
  • New accessor never raises on the no-dispatch and non-owning-controller paths

Summary by CodeRabbit

  • New Features
    • Added read-only access for commands to retrieve their dispatched command metadata during execution.
    • Added read-only access for the default/no-subcommand path to retrieve default command metadata when available.
  • Documentation
    • Updated changelog entries and added branching policy guidance.
  • Tests
    • Expanded coverage for dispatched vs non-dispatched behavior, controller ownership edge cases, and default command metadata scenarios.

derks and others added 5 commits June 25, 2026 16:12
- Add ArgparseController._command_meta read-only @Property returning the
  currently-dispatched command's CommandMeta, or None outside a dispatched
  command (no __dispatch__ on pargs); never raises (D-01..D-04).
- Resolve meta via class-level getattr(self.__class__, func_name) to match
  the shared CommandMeta whose .controller is mutated in _collect_commands
  (D-07 sharing caveat).
- Leave func() dispatch signature unchanged (additive-only, 3.0.x BC);
  record Cement 4 func-signature-injection deferral as a comment (D-06/D-07).
- Resolves #670 ergonomic call site self._command_meta.parser_options['help'].
- Add command_meta_cmd fixture on Base reading self._command_meta inside its
  body and returning label + parser_options['help'] (the #670 call site).
- test_command_meta_dispatched asserts the @expose(help=...) string and label
  round-trip (D-02/D-04).
- test_command_meta_none_outside_dispatch asserts None on the no-sub-command
  path where pargs has no __dispatch__ (D-03), via Base._default body read.
- Update test_base_default to match _default's new meta-aware return string.
- 100% coverage on cement/ext/ext_argparse.py with zero misses.
- Add [ext.argparse] Features entry under the active 3.0.15 - DEVELOPMENT
  section documenting the read-only self._command_meta accessor.
- Note it is additive (func() dispatch signature unchanged) and returns None
  outside a dispatched command.
- Trailing [Issue #670] sub-bullet per the established entry shape.
Code review WR-01: _command_meta also runs on controllers that do not
own the dispatched command (e.g. via _post_argument_parsing). Guard on
the __cement_meta__ marker instead of bare attribute existence so a
same-named, non-command attribute yields None rather than raising
AttributeError. Collapses the guard and removes the pragma: nocover
defensive line; adds a non-owning-controller regression test. Still
additive and read-only -- no 3.0.x public-API change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… consent

Add a Branching Policy section to CLAUDE.md codifying that no work --
implementation, tests, docs, or planning artifacts -- may be committed
directly to main without explicit consent, and that a branch must be
created immediately when phase/task work begins (not deferred to
execution). Notes that GSD branching_strategy defaults to 'none' and
must not be relied on to branch automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 08205dbd-fd78-4855-9abb-fbc1a3d9bd3c

📥 Commits

Reviewing files that changed from the base of the PR and between e0ffb2a and 8697aa7.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • cement/ext/ext_argparse.py
  • tests/ext/test_ext_argparse.py
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md

📝 Walkthrough

Walkthrough

Added _command_meta and _default_command_meta on ArgparseController, updated tests for dispatched and default-command metadata access, documented both additions in CHANGELOG.md, and added branching policy guidance to CLAUDE.md.

Changes

Argparse command metadata

Layer / File(s) Summary
Metadata lookup and release note
cement/ext/ext_argparse.py, CHANGELOG.md
ArgparseController resolves dispatched and default command metadata, and CHANGELOG.md documents both new read-only properties.
Metadata behavior tests
tests/ext/test_ext_argparse.py
Base.default, the dispatched command path, non-dispatched path, non-owning controller path, and default-command cases are updated to assert the new metadata accessors.
Branching rules
CLAUDE.md
CLAUDE.md adds branch creation, naming, and merge restriction instructions relative to main.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

Hop! Two meta paths now softly glow,
One for dispatch, one for default flow.
Tests nibble branches, neat and spry,
While policy rules reach to the sky.
🐰 Thump-thump—new command lore says hi.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change by adding the new read-only _command_meta accessor in ext.argparse.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gsd/phase-05.2-ext-argparse-command-self-meta-accessor-670-pr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cement/ext/ext_argparse.py`:
- Around line 382-393: The current command lookup in the `CommandMeta`-returning
property only uses `func_name`, so a non-owning controller with the same method
name can incorrectly return its own `__cement_meta__`. Update the lookup to also
verify the dispatched controller label from `self.app.pargs.__dispatch__`
matches the owning controller before reading `member` and `meta`, and return
`None` when the label does not match. Keep the fix localized to the property
that resolves `CommandMeta` and preserve the existing never-raises behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8527463a-230d-4d5b-9466-56c45070fafb

📥 Commits

Reviewing files that changed from the base of the PR and between e1bf740 and e0ffb2a.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • CLAUDE.md
  • cement/ext/ext_argparse.py
  • tests/ext/test_ext_argparse.py

Comment on lines +382 to +393
if not hasattr(self.app.pargs, '__dispatch__'):
return None
func_name = self.app.pargs.__dispatch__.split('.')[1]
# Resolve the class-level command function and read its CommandMeta.
# Guarding on the __cement_meta__ marker (rather than mere attribute
# existence) keeps the never-raises contract: this property can also
# run on a non-owning controller (e.g. via _post_argument_parsing),
# whose class may carry a same-named, non-command attribute -- that
# yields None here instead of an AttributeError (WR-01).
member = getattr(self.__class__, func_name, None)
meta: CommandMeta | None = getattr(member, '__cement_meta__', None)
return meta

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Gate the lookup on the dispatched controller label.

Right now this only keys off func_name, so a non-owning controller that happens to expose the same method name will return its own __cement_meta__ instead of None. That breaks the new “current command” contract even though the non-command collision test passes.

Suggested fix
-        if not hasattr(self.app.pargs, '__dispatch__'):
+        dispatch = getattr(self.app.pargs, '__dispatch__', None)
+        if dispatch is None:
             return None
-        func_name = self.app.pargs.__dispatch__.split('.')[1]
+        contr_label, _, func_name = dispatch.partition('.')
+        if not func_name or contr_label != self._meta.label:
+            return None
         # Resolve the class-level command function and read its CommandMeta.
         # Guarding on the __cement_meta__ marker (rather than mere attribute
         # existence) keeps the never-raises contract: this property can also
         # run on a non-owning controller (e.g. via _post_argument_parsing),
         # whose class may carry a same-named, non-command attribute -- that
         # yields None here instead of an AttributeError (WR-01).
         member = getattr(self.__class__, func_name, None)
         meta: CommandMeta | None = getattr(member, '__cement_meta__', None)
         return meta
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not hasattr(self.app.pargs, '__dispatch__'):
return None
func_name = self.app.pargs.__dispatch__.split('.')[1]
# Resolve the class-level command function and read its CommandMeta.
# Guarding on the __cement_meta__ marker (rather than mere attribute
# existence) keeps the never-raises contract: this property can also
# run on a non-owning controller (e.g. via _post_argument_parsing),
# whose class may carry a same-named, non-command attribute -- that
# yields None here instead of an AttributeError (WR-01).
member = getattr(self.__class__, func_name, None)
meta: CommandMeta | None = getattr(member, '__cement_meta__', None)
return meta
dispatch = getattr(self.app.pargs, '__dispatch__', None)
if dispatch is None:
return None
contr_label, _, func_name = dispatch.partition('.')
if not func_name or contr_label != self._meta.label:
return None
# Resolve the class-level command function and read its CommandMeta.
# Guarding on the __cement_meta__ marker (rather than mere attribute
# existence) keeps the never-raises contract: this property can also
# run on a non-owning controller (e.g. via _post_argument_parsing),
# whose class may carry a same-named, non-command attribute -- that
# yields None here instead of an AttributeError (WR-01).
member = getattr(self.__class__, func_name, None)
meta: CommandMeta | None = getattr(member, '__cement_meta__', None)
return meta
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cement/ext/ext_argparse.py` around lines 382 - 393, The current command
lookup in the `CommandMeta`-returning property only uses `func_name`, so a
non-owning controller with the same method name can incorrectly return its own
`__cement_meta__`. Update the lookup to also verify the dispatched controller
label from `self.app.pargs.__dispatch__` matches the owning controller before
reading `member` and `meta`, and return `None` when the label does not match.
Keep the fix localized to the property that resolves `CommandMeta` and preserve
the existing never-raises behavior.

@derks

derks commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

@TomFreudenberg does this meet your needs? From within a controller method/command:

@ex(help='my command does the thing')
def my_command(self):
    cmd_meta = self._command_meta               # CommandMeta | None
    my_help = cmd_meta.parser_options['help']   # -> 'my command does the thing'

@TomFreudenberg

Copy link
Copy Markdown
Contributor

Hi @derks

it is exactly handled as I do currently within

https://github.com/tokeo/tokeo/blob/master/tokeo/core/utils/controllers.py


maybe you also add the access to the default subcommand mtea?

def defaultmeta(controller):
    """
    Access the \\_\\_cement_meta\\_\\_ information of default subcommand within the
    command.

    ### Args

    - **controller** (Controller): The controller instance

    ### Returns

    - **\\_\\_cement_meta\\_\\_** (Meta): object ref to Meta information of the
      _default_ subcommand

    """
    return getattr(controller, controller._meta.default_func).__cement_meta__

Companion to _command_meta for the default sub-command path. argparse
has no native default sub-command, so Cement reaches the default via
Meta.default_func rather than the __dispatch__ token -- which is why
_command_meta is None while the default function runs. _default_command_meta
resolves the default function's CommandMeta directly so a default command
can read its own @ex/@expose meta.

Returns None when default_func is None or points to a non-exposed function
(e.g. the framework's stock _default print_help), mirroring the never-raises
contract. Additive and read-only -- no 3.0.x public-API change. Addresses
@TomFreudenberg's PR #787 request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@derks

derks commented Jun 29, 2026

Copy link
Copy Markdown
Member Author

@TomFreudenberg added _default_command_meta for exactly this.

The default path can't reuse _command_meta because argparse has no native default sub-command — Cement reaches the default via Meta.default_func, so __dispatch__ is never set while the default function runs (which is why _command_meta is None there). The new accessor resolves default_func's meta directly.

One thing to flag on your defaultmeta helper: it raises AttributeError if the default isn't @ex/@expose-decorated (e.g. the framework's stock _default is just print_help, so it has no __cement_meta__). The new property guards that and returns None instead — and also handles default_func = None. Never raises.

Usage:

@ex(help='my command does the thing')
def _default(self):
    cmd_meta = self._default_command_meta          # CommandMeta | None
    my_help = cmd_meta.parser_options['help']       # -> 'my command does the thing'

Pushing it up to this PR now.

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.

2 participants