|
| 1 | +# Specify CLI Command Architecture |
| 2 | + |
| 3 | +This document defines the target structure for multi-command groups in the |
| 4 | +Specify Python CLI. It explains where command handlers, shared infrastructure, |
| 5 | +command-private phases, nested command groups, and their tests belong. |
| 6 | + |
| 7 | +`src/specify_cli/extensions/` is the reference implementation. Apply this |
| 8 | +design incrementally when adding or refactoring other command groups; do not |
| 9 | +create extra modules merely to make a small command conform visually. |
| 10 | + |
| 11 | +## Design goals |
| 12 | + |
| 13 | +The CLI structure should make the answer to "where does this command live?" |
| 14 | +predictable from the command line itself. |
| 15 | + |
| 16 | +The design optimizes for: |
| 17 | + |
| 18 | +- **Direct navigation:** a command maps to an obvious source file and test. |
| 19 | +- **Small working context:** changing one command should not require loading an |
| 20 | + entire command group into memory. |
| 21 | +- **Parallel development:** unrelated commands should rarely require edits to |
| 22 | + the same file. |
| 23 | +- **Explicit ownership:** shared infrastructure and command-private behavior |
| 24 | + should not be mixed. |
| 25 | +- **Stable behavior:** structural refactoring must preserve registration, |
| 26 | + output, error handling, compatibility paths, and tests. |
| 27 | +- **Agentic development:** coding agents should be able to infer the relevant |
| 28 | + files from the CLI surface without broad repository searches. |
| 29 | + |
| 30 | +## Naming and ownership |
| 31 | + |
| 32 | +### Registered command modules |
| 33 | + |
| 34 | +Each real CLI command uses: |
| 35 | + |
| 36 | +```text |
| 37 | +command_<name>.py |
| 38 | +``` |
| 39 | + |
| 40 | +For example: |
| 41 | + |
| 42 | +```text |
| 43 | +specify extension add -> extensions/command_add.py |
| 44 | +specify extension set-priority -> extensions/command_set_priority.py |
| 45 | +specify extension update -> extensions/command_update.py |
| 46 | +``` |
| 47 | + |
| 48 | +Only modules representing actual CLI commands use the non-underscored |
| 49 | +`command_*.py` prefix. A command module owns: |
| 50 | + |
| 51 | +- The Typer-decorated handler. |
| 52 | +- User-facing arguments and options. |
| 53 | +- Command-specific orchestration. |
| 54 | +- Small helpers used only by that command. |
| 55 | + |
| 56 | +The command function's docstring is user-facing because Typer may display it |
| 57 | +as help text. A module docstring is internal and should identify the command, |
| 58 | +registration path, and any adjacent private implementation modules. |
| 59 | + |
| 60 | +### Command-private implementation modules |
| 61 | + |
| 62 | +When a command has cohesive phases that are independently understandable or |
| 63 | +testable, use: |
| 64 | + |
| 65 | +```text |
| 66 | +_command_<name>_<phase>.py |
| 67 | +``` |
| 68 | + |
| 69 | +For example: |
| 70 | + |
| 71 | +```text |
| 72 | +command_update.py |
| 73 | +_command_update_discovery.py |
| 74 | +_command_update_artifacts.py |
| 75 | +_command_update_transaction.py |
| 76 | +``` |
| 77 | + |
| 78 | +The leading underscore marks the module as private implementation. The |
| 79 | +`command_update` portion groups it with the registered handler in searches and |
| 80 | +file listings. The phase suffix communicates its ownership. |
| 81 | + |
| 82 | +Private phase modules must not register additional CLI commands. The public |
| 83 | +`command_<name>.py` module remains the sole CLI adapter. |
| 84 | + |
| 85 | +Split a command when a phase: |
| 86 | + |
| 87 | +- Has distinct invariants or failure behavior. |
| 88 | +- Can be tested as a meaningful boundary. |
| 89 | +- Has enough implementation detail to distract from the CLI handler. |
| 90 | +- Is likely to change independently from other phases. |
| 91 | + |
| 92 | +Do not split a command solely because it crossed an arbitrary line count. |
| 93 | +Excessive fragmentation makes control flow harder to follow and increases the |
| 94 | +number of files an agent must inspect. |
| 95 | + |
| 96 | +### Command-group infrastructure |
| 97 | + |
| 98 | +For a multi-command group, `_commands.py` owns: |
| 99 | + |
| 100 | +- The command group's Typer application. |
| 101 | +- Registration of the group's command modules. |
| 102 | +- Infrastructure genuinely shared by multiple commands or external CLI flows. |
| 103 | +- Thin compatibility forwarders needed to preserve established import or |
| 104 | + monkeypatch paths. |
| 105 | + |
| 106 | +`_commands.py` must not contain decorated command handlers. A helper used by |
| 107 | +only one command belongs in that command's module or one of its private phase |
| 108 | +modules. |
| 109 | + |
| 110 | +Compatibility forwarders do not transfer ownership back to `_commands.py`. |
| 111 | +They should remain thin and delegate to the module that owns the behavior. |
| 112 | +Avoid turning `_commands.py` into a service locator for new code. |
| 113 | + |
| 114 | +### Package `__init__.py` |
| 115 | + |
| 116 | +The package `__init__.py` owns the package's domain API and package-level |
| 117 | +behavior. It should provide a brief map to the CLI modules, but it is not the |
| 118 | +home for command handlers. |
| 119 | + |
| 120 | +Moving command handlers out of `__init__.py` keeps importing the domain package |
| 121 | +separate from understanding or modifying its CLI surface. |
| 122 | + |
| 123 | +## Nested command groups |
| 124 | + |
| 125 | +Nested CLI groups use directories matching the command surface: |
| 126 | + |
| 127 | +```text |
| 128 | +specify extension catalog add |
| 129 | + list |
| 130 | + remove |
| 131 | +``` |
| 132 | + |
| 133 | +maps to: |
| 134 | + |
| 135 | +```text |
| 136 | +extensions/ |
| 137 | +├── catalog/ |
| 138 | +│ ├── __init__.py |
| 139 | +│ ├── _helpers.py |
| 140 | +│ ├── command_add.py |
| 141 | +│ ├── command_list.py |
| 142 | +│ └── command_remove.py |
| 143 | +├── command_add.py |
| 144 | +├── command_list.py |
| 145 | +└── ... |
| 146 | +``` |
| 147 | + |
| 148 | +The nested package's `__init__.py` owns its Typer application and registration. |
| 149 | +Shared helpers for that nested surface can live in `_helpers.py`. |
| 150 | + |
| 151 | +Do not add a nested `_commands.py` merely for symmetry. Create one only when |
| 152 | +the nested group develops substantial shared command infrastructure that no |
| 153 | +longer fits cleanly in `__init__.py` and `_helpers.py`. |
| 154 | + |
| 155 | +Do not create a nested directory for an implementation phase that is not a CLI |
| 156 | +subcommand. For example, an `update/` directory would incorrectly suggest an |
| 157 | +`extension update ...` subcommand group. Use `_command_update_<phase>.py` |
| 158 | +instead. |
| 159 | + |
| 160 | +## Registration |
| 161 | + |
| 162 | +Command registration remains centralized at the command-group boundary. |
| 163 | + |
| 164 | +For the extension group: |
| 165 | + |
| 166 | +1. `src/specify_cli/extensions/_commands.py` owns `extension_app`. |
| 167 | +2. `_commands.register()` registers the nested catalog group. |
| 168 | +3. It imports each `command_*.py` module so its decorator registers the |
| 169 | + handler. |
| 170 | +4. It attaches `extension_app` to the root application. |
| 171 | + |
| 172 | +The nested catalog group follows the same pattern through |
| 173 | +`catalog.register()`. |
| 174 | + |
| 175 | +Registration imports should be explicit and ordered consistently. Do not rely |
| 176 | +on filesystem discovery to import arbitrary modules, because command exposure |
| 177 | +should remain reviewable in one place. |
| 178 | + |
| 179 | +## Test structure |
| 180 | + |
| 181 | +Command-focused tests mirror the source command surface under |
| 182 | +`tests/specify_cli/`. |
| 183 | + |
| 184 | +For example: |
| 185 | + |
| 186 | +```text |
| 187 | +src/specify_cli/extensions/command_add.py |
| 188 | +tests/specify_cli/extensions/test_command_add.py |
| 189 | +
|
| 190 | +src/specify_cli/extensions/catalog/command_add.py |
| 191 | +tests/specify_cli/extensions/catalog/test_command_add.py |
| 192 | +``` |
| 193 | + |
| 194 | +Private phases use: |
| 195 | + |
| 196 | +```text |
| 197 | +src/specify_cli/extensions/_command_update_discovery.py |
| 198 | +tests/specify_cli/extensions/test_command_update_discovery.py |
| 199 | +
|
| 200 | +src/specify_cli/extensions/_command_update_artifacts.py |
| 201 | +tests/specify_cli/extensions/test_command_update_artifacts.py |
| 202 | +
|
| 203 | +src/specify_cli/extensions/_command_update_transaction.py |
| 204 | +tests/specify_cli/extensions/test_command_update_transaction.py |
| 205 | +``` |
| 206 | + |
| 207 | +The primary `test_command_<name>.py` suite verifies the public command surface. |
| 208 | +Phase-specific suites verify detailed invariants without obscuring the primary |
| 209 | +command behavior. |
| 210 | + |
| 211 | +Not every test belongs in the mirrored command tree: |
| 212 | + |
| 213 | +- Domain model, registry, manager, and catalog behavior remains in domain test |
| 214 | + suites such as `tests/test_extensions.py`. |
| 215 | +- Cross-domain CLI contracts remain with the broader integration tests. |
| 216 | +- Shared fixtures belong in the narrowest `conftest.py` that serves all of |
| 217 | + their consumers. |
| 218 | +- Test helpers should be shared rather than copied when both command and domain |
| 219 | + tests depend on the same behavior. |
| 220 | + |
| 221 | +Moving tests must preserve coverage rather than duplicating it. Run both the |
| 222 | +new command-focused suites and the legacy suites from which tests were moved. |
| 223 | + |
| 224 | +## Reference layout |
| 225 | + |
| 226 | +The extension command group currently demonstrates the complete pattern: |
| 227 | + |
| 228 | +```text |
| 229 | +src/specify_cli/extensions/ |
| 230 | +├── __init__.py |
| 231 | +├── _commands.py |
| 232 | +├── command_add.py |
| 233 | +├── command_disable.py |
| 234 | +├── command_enable.py |
| 235 | +├── command_info.py |
| 236 | +├── command_list.py |
| 237 | +├── command_remove.py |
| 238 | +├── command_search.py |
| 239 | +├── command_set_priority.py |
| 240 | +├── command_update.py |
| 241 | +├── _command_update_discovery.py |
| 242 | +├── _command_update_artifacts.py |
| 243 | +├── _command_update_transaction.py |
| 244 | +└── catalog/ |
| 245 | + ├── __init__.py |
| 246 | + ├── _helpers.py |
| 247 | + ├── command_add.py |
| 248 | + ├── command_list.py |
| 249 | + └── command_remove.py |
| 250 | +``` |
| 251 | + |
| 252 | +The update command illustrates the distinction: |
| 253 | + |
| 254 | +- `command_update.py` is the registered CLI adapter. |
| 255 | +- `_command_update_discovery.py` determines available updates. |
| 256 | +- `_command_update_artifacts.py` prepares and validates update archives. |
| 257 | +- `_command_update_transaction.py` owns backup, installation, rollback, and |
| 258 | + cleanup behavior. |
| 259 | + |
| 260 | +## Decision guide |
| 261 | + |
| 262 | +When deciding where code belongs: |
| 263 | + |
| 264 | +| Question | Location | |
| 265 | +|---|---| |
| 266 | +| Does it define a real CLI command? | `command_<name>.py` | |
| 267 | +| Is it used only by one small command? | That command module | |
| 268 | +| Is it a cohesive private phase of one complex command? | `_command_<name>_<phase>.py` | |
| 269 | +| Is it shared by multiple commands or an external CLI flow? | `_commands.py` or a focused shared module | |
| 270 | +| Does it define a nested CLI namespace? | A directory matching that namespace | |
| 271 | +| Is it shared only by commands in a nested namespace? | The nested package's `_helpers.py` | |
| 272 | +| Is it domain behavior independent of the CLI? | The package domain modules, not command modules | |
| 273 | + |
| 274 | +## Anti-patterns |
| 275 | + |
| 276 | +Avoid: |
| 277 | + |
| 278 | +- Adding decorated handlers back to `_commands.py` or package `__init__.py`. |
| 279 | +- Naming a private implementation module `command_*.py`. |
| 280 | +- Creating nested directories that do not correspond to CLI namespaces. |
| 281 | +- Creating `_commands.py` files only for visual symmetry. |
| 282 | +- Moving command-private helpers into shared infrastructure preemptively. |
| 283 | +- Duplicating fixtures or helpers to make tests appear more mirrored. |
| 284 | +- Splitting a linear function into many files without cohesive phase |
| 285 | + boundaries. |
| 286 | +- Changing established monkeypatch or import paths without either migrating |
| 287 | + their consumers or preserving a thin compatibility forwarder. |
| 288 | + |
| 289 | +## Review checklist |
| 290 | + |
| 291 | +For a new or refactored command: |
| 292 | + |
| 293 | +- [ ] The CLI path maps predictably to a `command_<name>.py` module. |
| 294 | +- [ ] Only the real command module registers a handler. |
| 295 | +- [ ] Private phase modules use `_command_<name>_<phase>.py`. |
| 296 | +- [ ] `_commands.py` contains only group infrastructure and genuinely shared |
| 297 | + behavior. |
| 298 | +- [ ] Nested directories correspond to real CLI namespaces. |
| 299 | +- [ ] Command tests mirror the source structure. |
| 300 | +- [ ] Domain and cross-domain tests remain in their appropriate suites. |
| 301 | +- [ ] Compatibility paths and user-visible help remain unchanged unless the |
| 302 | + change explicitly requires otherwise. |
| 303 | +- [ ] Focused tests, relevant legacy suites, lint, and the full test suite pass. |
| 304 | + |
0 commit comments