Rendering Refactor Plan
Rendering refactor plan
Section titled “Rendering refactor plan”Replace reflection-based output in rendering.py with typed Pydantic results and explicit
singledispatchmethod renderers. Collapse redundant command nesting where it adds no value.
- Commands return typed values (
KSeFBaseModel,list[...], or CLI-owned Pydantic models). - JSON output uses
model_dump(mode="json")— no custom_to_jsonablewalk. - Plain text output uses registered handlers per type — no
_primary_collectionheuristics. - Remove
collection(), ad-hocdict[str, str]success payloads, and duplicate settings access. - Simple commands use one entry helper (
run_authenticated_command/run_client_command).
Non-goals
Section titled “Non-goals”- Adding a service layer between commands and the SDK.
- Changing Typer option surfaces or command names.
- Rewriting
sdk_models.pybeyond what migration requires. - Publishing user-facing docs until the refactor lands (update
architecture.mdonly).
Target architecture
Section titled “Target architecture”User invocation → app.py (Settings on ctx.obj) → commands/*.py (parse options, call SDK, return Renderable) → run_command (try / render / errors) → run_authenticated | run_client (SDK lifecycle, when needed) → renderers.render(ctx, result) → JsonRenderer | PlainTextRenderer (@singledispatchmethod) → stdout (errors → stderr via exceptions.py)Runtime layers (what stays)
Section titled “Runtime layers (what stays)”| Layer | Module | Responsibility |
|---|---|---|
| Shell | app.py | Typer, global options, Settings |
| Pipeline | context.py | run_command, auth/client wrappers, errors |
| Workflow | commands/*.py | Option → SDK call → Renderable |
| Output | renderers/ | JSON/text formatting |
Nesting rule for commands
Section titled “Nesting rule for commands”| Command shape | Pattern |
|---|---|
| Single SDK call | run_authenticated_command(ctx, lambda auth: ...) |
| Public SDK call | run_client_command(ctx, lambda client: ...) |
| Void mutation | run_authenticated_command(ctx, work) where work returns ActionResult |
| Multi-step (file I/O, loops) | Named work(auth) closure, still one entry helper |
| Avoid | def operation() that only forwards to run_authenticated |
New module layout
Section titled “New module layout”src/ksef2_cli/ results.py CLI-owned Pydantic result models + Renderable alias renderers/ __init__.py render(ctx, result) json.py JsonRenderer + json_renderer singleton json_cli.py JSON handlers for CLI-only types (FocusedResult, …) text.py PlainTextRenderer + plain_renderer singleton rows/ __init__.py import all row modules (registration side effects) tokens.py peppol.py invoices.py sessions.py … one file per domain as handlers are added context.py updated helpers (see below) rendering.py DELETE after migration (or thin re-export during transition)results.py
Section titled “results.py”Base type — not KSeFBaseModel (no API extra-field logging, no camelCase aliases):
class CliResult(BaseModel): model_config = ConfigDict(frozen=True)CLI-only models
Section titled “CLI-only models”| Model | Replaces | Notes |
|---|---|---|
SavedFile | {"path", "bytes"} | Use Field(serialization_alias="bytes") on size for JSON compat |
ActionResult | {"revoked": "true"}, etc. | Optional fields; bools for flags |
FocusedResult | collection(payload, items) | JSON → payload, text → items |
ExportHandleSaved | export dict + optional handle_file | Move logic from _export_handle_to_dict |
ExportPaths | export-fetch / export-download payload | reference_number, paths, optional handle_file |
SessionOpened | {"state_file", "state"} | online/batch open |
BatchOpened | batch open composite | state + status |
OnlineSendResult | online send payload | or FocusedResult if simpler |
ConfigPathInfo | config path dict | |
ConfigShowResult | config show dict | mask secrets in serializer |
ConfigInitResult | config init dict |
Renderable alias
Section titled “Renderable alias”type Renderable = BaseModel | list[BaseModel]SDK response models and CliResult subclasses are all BaseModel. Lists appear when --all
flattens pages.
Renderers
Section titled “Renderers”JsonRenderer
Section titled “JsonRenderer”Registrations (minimal set):
BaseModel→model_dump(mode="json", by_alias=True)list→ list of dumpsFocusedResult→ delegate topayload- Overrides only where needed (e.g.
ConfigShowResultsecret masking)
PlainTextRenderer
Section titled “PlainTextRenderer”Registrations added incrementally per migration PR. Required handlers:
list→ joinrender(item)per element (recursive dispatch)- One handler per SDK type that appears in text output
- All
CliResultsubclasses
No generic BaseModel fallback for text — missing handler raises TypeError and fails tests.
Entry point
Section titled “Entry point”def render(ctx: typer.Context, result: Renderable) -> None: settings = get_settings(ctx) renderer = json_renderer if settings.output is OutputMode.json else plain_renderer sys.stdout.write(renderer.render(result) + "\n")Import rows package from renderers/__init__.py so handlers register at startup.
context.py changes
Section titled “context.py changes”get_settings,create_client,use_client,run_client,run_authenticatedauthenticate_client, credential loaders,read_model,failrun_command(update type hint toCallable[[], Renderable])
def run_authenticated_command(ctx, work: Callable[[Any], Renderable]) -> None: run_command(ctx, lambda: run_authenticated(ctx, work))
def run_client_command(ctx, work: Callable[[Client], Renderable]) -> None: run_command(ctx, lambda: run_client(ctx, work))Remove
Section titled “Remove”- Old
run_client_commandbody that duplicateduse_clientdifferently (fold into above) - Import from deleted
rendering.py; import fromrenderers
io.py addition
Section titled “io.py addition”def write_bytes_file(path: Path, content: bytes) -> Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(content) return pathUse in download/upo commands instead of inline mkdir + write_bytes.
PR sequence
Section titled “PR sequence”PR 1 — Foundation + proof (tokens, peppol)
Section titled “PR 1 — Foundation + proof (tokens, peppol)”Scope
- Add
results.pywithCliResult,ActionResult,Renderable - Add
renderers/skeleton withJsonRenderer,PlainTextRenderer, singledispatch - Register JSON handlers for
BaseModel,list,FocusedResult(stub if unused yet) - Register text handlers:
GenerateTokenResponse,TokenStatusResponse,QueryTokensResponse,TokenInfo,TokenInfovia list,ListPeppolProvidersResponse,PeppolProvider,ActionResult - Update
context.pywith new command helpers - Migrate
commands/tokens.py,commands/peppol.py - Migrate
commands/auth.pyto unifiedrun_client_command - Unit tests: JSON dump, text rows,
ActionResult, list recursion - Keep old
rendering.pyunused or re-exportrenderfromrenderersfor other commands
Acceptance
uv run pytest tests/unit/test_renderers*.py tests/component/...tokens...ksef2 tokens list,tokens revoke,peppol providerswork in text and--json- No
collection()ordictreturns in migrated commands
PR 2 — File results (SavedFile, downloads)
Section titled “PR 2 — File results (SavedFile, downloads)”Scope
- Add
SavedFile,write_bytes_file - Text handler for
SavedFile - Migrate:
invoices download,online upo,batch upo
Acceptance
- JSON still exposes
"bytes"field name - Text shows
pathandsize/bytesconsistently
PR 3 — Export and focused results
Section titled “PR 3 — Export and focused results”Scope
- Add
ExportHandleSaved,ExportPaths,FocusedResult - JSON/text handlers for export types
- Migrate:
invoices export,export-fetch,export-download - Replace all
collection()usages ininvoices.py
Acceptance
- JSON export-fetch returns full payload; text lists paths only (parity with today)
PR 4 — Session workflows
Section titled “PR 4 — Session workflows”Scope
- Add
SessionOpened,BatchOpened,OnlineSendResult(orFocusedResult) - Migrate:
online.py(open, send, close),batch.py(open, close paths) - Replace remaining
ActionResultdicts in sessions/online/batch
Acceptance
- Multi-invoice send text output readable; JSON unchanged structurally
PR 5 — Void actions and config
Section titled “PR 5 — Void actions and config”Scope
- Migrate
sessions.py,certificates.py(revoke),limits.py,testdata.py - Add config result models + secret-safe JSON for
config show/init - Migrate
commands/config.py
Acceptance
- All
{"…": "true"}dict returns gone from codebase
PR 6 — Remaining SDK passthrough handlers
Section titled “PR 6 — Remaining SDK passthrough handlers”Scope
- Register plain-text handlers for types returned by:
permissions.pycertificates.py(queries)encryption.pylimits getinvoices metadata,export-statussessions invoice-list,auth-listbatch status,list
- Collapse unnecessary
def operation()wrappers in touched files
Acceptance
- Every command path covered by a registered text handler
- Grep for
def operation()only where multi-step logic exists
PR 7 — Cleanup
Section titled “PR 7 — Cleanup”Scope
- Delete
rendering.py(andCollection,collection(), reflection helpers) - Remove dead tests in
test_helpers.py; add/organize renderer tests - Update
docs/contributing/architecture.mdto describe new flow - Delete this plan doc or mark complete
Acceptance
rg '_primary_collection|collection\\(|_to_jsonable|_plain_text' src/→ empty- Full
uv run pytestgreen
Command migration checklist
Section titled “Command migration checklist”For each command handler:
- Return type is
Renderable(noAny, no baredict) - Uses
run_authenticated_commandorrun_client_command(orrun_commandonly for config-style local work) - Void SDK calls return
ActionResult(...)with bool fields - File writes go through
io.write_bytes_file/_write_json - Text/JSON split uses
FocusedResult, notcollection() - Plain-text handler registered for every new return type
- Component or unit test covers
--jsonand default text output
Testing strategy
Section titled “Testing strategy”Unit (tests/unit/test_renderers/)
Section titled “Unit (tests/unit/test_renderers/)”test_json.py— model dump, list,FocusedResult, alias fields (SavedFile.bytes)test_text_tokens.py, etc. — golden strings per handlertest_action_result.py— bool →yes/noin text,true/falsein JSON
Existing tests to update
Section titled “Existing tests to update”tests/unit/test_helpers.py— remove rendering reflection tests; keep parsing/io/sdk_modelstests/unit/test_context.py— adjust imports ifrendermoves
Component
Section titled “Component”- Keep
CliRunnersmoke tests; update expected output when text layout is intentionally defined - Snapshot critical
--jsonoutputs before PR 1 for regression comparison
Compatibility notes
Section titled “Compatibility notes”| Topic | Decision |
|---|---|
JSON field bytes on file results | Keep via serialization alias |
| Text heuristic unwrapping single list field | Removed — explicit handlers instead |
collection() JSON vs text | FocusedResult preserves behavior |
String "true" flags | Become JSON booleans in ActionResult (breaking for scripts relying on strings — document in PR 5) |
Definition of done
Section titled “Definition of done”- All commands return
Renderable -
rendering.pydeleted; output only throughrenderers/ -
singledispatchmethodon both renderers; row handlers inrenderers/rows/ - No
collection(), no reflection formatters, no duplicateget_settingsin renderers -
architecture.mdupdated - Full test suite passes
Execution order for contributors
Section titled “Execution order for contributors”- Read this plan and
architecture.md. - Land PR 1 before touching session/export workflows.
- When adding a command or return type, register text handler in the same PR.
- Do not reintroduce
Anyreturn types or dict payloads for success output.