impose-cli 0.4.0__tar.gz → 1.0.0rc0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. impose_cli-1.0.0rc0/.changeset/README.md +9 -0
  2. impose_cli-1.0.0rc0/.gitignore +19 -0
  3. impose_cli-1.0.0rc0/PKG-INFO +551 -0
  4. impose_cli-1.0.0rc0/README.md +540 -0
  5. impose_cli-1.0.0rc0/examples/__init__.py +1 -0
  6. impose_cli-1.0.0rc0/examples/admin_tool/README.md +99 -0
  7. impose_cli-1.0.0rc0/examples/admin_tool/__init__.py +1 -0
  8. impose_cli-1.0.0rc0/examples/admin_tool/admin_tool/__init__.py +1 -0
  9. impose_cli-1.0.0rc0/examples/admin_tool/admin_tool/application.py +33 -0
  10. impose_cli-1.0.0rc0/examples/admin_tool/admin_tool/data.py +37 -0
  11. impose_cli-1.0.0rc0/examples/admin_tool/admin_tool/middleware.py +37 -0
  12. impose_cli-1.0.0rc0/examples/admin_tool/admin_tool/storage.py +22 -0
  13. impose_cli-1.0.0rc0/examples/admin_tool/admin_tool/users.py +39 -0
  14. impose_cli-1.0.0rc0/examples/admin_tool/api.py +8 -0
  15. impose_cli-1.0.0rc0/examples/admin_tool/cli.py +11 -0
  16. impose_cli-1.0.0rc0/integration/pytest.ini +5 -0
  17. impose_cli-1.0.0rc0/integration/test_admin_tool.py +136 -0
  18. impose_cli-1.0.0rc0/pyproject.toml +89 -0
  19. impose_cli-1.0.0rc0/src/impose/__init__.py +10 -0
  20. impose_cli-1.0.0rc0/src/impose/__main__.py +31 -0
  21. impose_cli-1.0.0rc0/src/impose/api.py +199 -0
  22. impose_cli-1.0.0rc0/src/impose/application.py +110 -0
  23. impose_cli-1.0.0rc0/src/impose/cli/__init__.py +7 -0
  24. impose_cli-1.0.0rc0/src/impose/cli/arguments.py +180 -0
  25. impose_cli-1.0.0rc0/src/impose/cli/builder.py +132 -0
  26. impose_cli-1.0.0rc0/src/impose/cli/confirmation.py +86 -0
  27. impose_cli-1.0.0rc0/src/impose/cli/formatting.py +75 -0
  28. impose_cli-1.0.0rc0/src/impose/commands.py +55 -0
  29. impose_cli-1.0.0rc0/src/impose/decorator.py +77 -0
  30. impose_cli-1.0.0rc0/src/impose/discovery.py +105 -0
  31. impose_cli-1.0.0rc0/src/impose/docstrings.py +34 -0
  32. impose_cli-1.0.0rc0/src/impose/middleware.py +68 -0
  33. impose_cli-1.0.0rc0/src/impose/values.py +115 -0
  34. impose_cli-1.0.0rc0/src/tests/helpers.py +22 -0
  35. impose_cli-1.0.0rc0/src/tests/test_api.py +382 -0
  36. impose_cli-1.0.0rc0/src/tests/test_api_fuzz.py +99 -0
  37. impose_cli-1.0.0rc0/src/tests/test_cli_arguments.py +90 -0
  38. impose_cli-1.0.0rc0/src/tests/test_cli_confirmation.py +108 -0
  39. impose_cli-1.0.0rc0/src/tests/test_cli_runtime.py +65 -0
  40. impose_cli-1.0.0rc0/src/tests/test_commands.py +37 -0
  41. impose_cli-1.0.0rc0/src/tests/test_decorator.py +52 -0
  42. impose_cli-1.0.0rc0/src/tests/test_discovery.py +132 -0
  43. impose_cli-1.0.0rc0/src/tests/test_formatting.py +40 -0
  44. impose_cli-1.0.0rc0/src/tests/test_integration.py +96 -0
  45. impose_cli-1.0.0rc0/src/tests/test_main.py +27 -0
  46. impose_cli-1.0.0rc0/src/tests/test_values.py +60 -0
  47. impose_cli-1.0.0rc0/unit/pytest.ini +9 -0
  48. impose_cli-1.0.0rc0/unit/test_unit.py +33 -0
  49. impose-cli-0.4.0/LICENSE +0 -21
  50. impose-cli-0.4.0/PKG-INFO +0 -11
  51. impose-cli-0.4.0/README.md +0 -25
  52. impose-cli-0.4.0/impose_cli/__init__.py +0 -4
  53. impose-cli-0.4.0/impose_cli/_utils.py +0 -275
  54. impose-cli-0.4.0/impose_cli/decorators.py +0 -15
  55. impose-cli-0.4.0/impose_cli/errors.py +0 -5
  56. impose-cli-0.4.0/impose_cli/impose_cli.py +0 -19
  57. impose-cli-0.4.0/impose_cli.egg-info/PKG-INFO +0 -11
  58. impose-cli-0.4.0/impose_cli.egg-info/SOURCES.txt +0 -15
  59. impose-cli-0.4.0/impose_cli.egg-info/dependency_links.txt +0 -1
  60. impose-cli-0.4.0/impose_cli.egg-info/requires.txt +0 -3
  61. impose-cli-0.4.0/impose_cli.egg-info/top_level.txt +0 -2
  62. impose-cli-0.4.0/setup.cfg +0 -4
  63. impose-cli-0.4.0/setup.py +0 -19
  64. impose-cli-0.4.0/tests/__init__.py +0 -0
  65. impose-cli-0.4.0/tests/simple.py +0 -23
@@ -0,0 +1,9 @@
1
+ # Changesets
2
+
3
+ Use Changesets to describe user-facing changes that should be included in the next release.
4
+
5
+ Create a changeset with:
6
+
7
+ ```sh
8
+ pnpm changeset
9
+ ```
@@ -0,0 +1,19 @@
1
+ impose-cli.iml
2
+ venv/
3
+ .idea/
4
+ .env
5
+ .direnv/
6
+ .pytest_cache/
7
+ .coverage
8
+ .coverage.*
9
+ coverage.xml
10
+ coverage.json
11
+ htmlcov/
12
+ .venv/
13
+ node_modules/
14
+ dist/
15
+ *.egg-info/
16
+ __pycache__/
17
+ **/__pycache__/
18
+ *.py[cod]
19
+ *.iml
@@ -0,0 +1,551 @@
1
+ Metadata-Version: 2.4
2
+ Name: impose-cli
3
+ Version: 1.0.0rc0
4
+ Summary: Command line tools for impose.
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: docstring-parser>=0.18
7
+ Requires-Dist: pydantic>=2.0
8
+ Provides-Extra: api
9
+ Requires-Dist: fastapi>=0.100; extra == 'api'
10
+ Description-Content-Type: text/markdown
11
+
12
+ # impose-cli
13
+
14
+ impose-cli is the fastest and least disruptive way to turn your project, or a subset of your project, into a CLI or API.
15
+
16
+ Create an `ImposeApplication()` and point it at a module. Impose will iterate through that module and its submodules, then create a CLI and optionally a FastAPI application that can be mounted into another FastAPI application.
17
+
18
+ Every function decorated with `@impose` becomes a command while remaining a normal Python function. Add `impose_api_method` when the function should also become an API endpoint.
19
+
20
+ ```python
21
+ from impose import ImposeApplication, impose
22
+
23
+
24
+ @impose(impose_cs="elbv2")
25
+ def list_load_balancers(region: str) -> list[str]:
26
+ ...
27
+
28
+
29
+ app = ImposeApplication()
30
+ ```
31
+
32
+ ## Disruption Free
33
+
34
+ The primary goal of Impose is to be disruption free. Adding `@impose` to a function does not change how the function is called from Python code, does not require the function to inherit from a framework type, and does not prevent the function from being imported and reused normally.
35
+
36
+ The decorator is a passthrough:
37
+
38
+ ```python
39
+ @impose(impose_cs="elbv2")
40
+ def list_load_balancers(region: str) -> list[str]:
41
+ ...
42
+
43
+
44
+ load_balancers = list_load_balancers("us-east-1")
45
+ ```
46
+
47
+ Impose-specific decorator options are namespaced with the `impose_` prefix, such as `impose_cs`. This keeps Impose configuration separate from the function's own parameters and limits the chance of keyword argument interference as the function evolves.
48
+
49
+ ## Command Sets
50
+
51
+ `@impose` accepts an `impose_cs` keyword argument, short for `impose_command_set`:
52
+
53
+ ```python
54
+ @impose(impose_cs="elbv2")
55
+ def describe_target_groups() -> list[str]:
56
+ ...
57
+ ```
58
+
59
+ All functions under the same command set automatically become subcommands under the same CLI group. Functions that also set `impose_api_method` become endpoints under the same API router path.
60
+
61
+ Command descriptions and per-argument help come from the function docstring, keeping the decorator focused on command registration.
62
+
63
+ ### Example: Explicit Command Sets
64
+
65
+ ```python
66
+ # cloud/elbv2.py
67
+ from impose import impose
68
+
69
+
70
+ @impose(impose_cs="elbv2")
71
+ def list_load_balancers(region: str) -> list[str]:
72
+ ...
73
+
74
+
75
+ @impose(impose_cs="elbv2")
76
+ def describe_target_groups(load_balancer_arn: str) -> list[str]:
77
+ ...
78
+ ```
79
+
80
+ ```python
81
+ # cli.py
82
+ import cloud.elbv2
83
+ from impose import ImposeApplication
84
+
85
+
86
+ application = ImposeApplication(modules=[cloud.elbv2])
87
+ cli = application.cli()
88
+ ```
89
+
90
+ The functions above become subcommands under the same `elbv2` group:
91
+
92
+ ```sh
93
+ impose elbv2 list-load-balancers us-east-1
94
+ impose elbv2 describe-target-groups arn:aws:...
95
+ ```
96
+
97
+ ## Project Structure
98
+
99
+ Impose can optionally use the actual project structure to dynamically create commands and subcommands. For example, functions under an `elbv2` folder can become subcommands of the `elbv2` command.
100
+
101
+ ### Example: Dynamic Project Structure
102
+
103
+ Given a project like this:
104
+
105
+ ```text
106
+ cloud_tools/
107
+ __init__.py
108
+ aws/
109
+ __init__.py
110
+ elbv2.py
111
+ rds.py
112
+ s3.py
113
+ github/
114
+ __init__.py
115
+ repos.py
116
+ cli.py
117
+ ```
118
+
119
+ ```python
120
+ # cloud_tools/aws/elbv2.py
121
+ from impose import impose
122
+
123
+
124
+ @impose
125
+ def list_load_balancers(region: str) -> list[str]:
126
+ ...
127
+
128
+
129
+ @impose
130
+ def describe_target_group(target_group_arn: str) -> dict[str, str]:
131
+ ...
132
+ ```
133
+
134
+ ```python
135
+ # cloud_tools/aws/s3.py
136
+ from impose import impose
137
+
138
+
139
+ @impose
140
+ def list_buckets(profile: str | None = None) -> list[str]:
141
+ ...
142
+ ```
143
+
144
+ ```python
145
+ # cloud_tools/aws/rds.py
146
+ from impose import impose
147
+
148
+
149
+ @impose
150
+ def reboot_instance(identifier: str, force_failover: bool = False) -> None:
151
+ ...
152
+ ```
153
+
154
+ ```python
155
+ # cloud_tools/github/repos.py
156
+ from impose import impose
157
+
158
+
159
+ @impose
160
+ def archive_repo(owner: str, repo: str) -> None:
161
+ ...
162
+ ```
163
+
164
+ Configure Impose to use the Python package structure as the command structure:
165
+
166
+ ```python
167
+ # cloud_tools/cli.py
168
+ import cloud_tools
169
+ from impose import ImposeApplication
170
+
171
+
172
+ application = ImposeApplication(
173
+ root_module=cloud_tools,
174
+ use_project_structure=True,
175
+ modules_as_subcommands=True,
176
+ )
177
+ cli = application.cli()
178
+ ```
179
+
180
+ `modules_as_subcommands` is a global setting that defaults to `True`. With the default behavior, Impose reads the modules below `cloud_tools`, finds functions decorated with `@impose`, and creates commands shaped like the package tree, including module filenames:
181
+
182
+ ```sh
183
+ impose aws elbv2 list-load-balancers us-east-1
184
+ impose aws elbv2 describe-target-group arn:aws:...
185
+ impose aws rds reboot-instance prod-db-1 --force-failover
186
+ impose aws s3 list-buckets --profile prod
187
+ impose github repos archive-repo example old-service
188
+ ```
189
+
190
+ The generated command hierarchy mirrors the source layout:
191
+
192
+ ```text
193
+ cloud_tools/aws/elbv2.py:list_load_balancers
194
+ -> impose aws elbv2 list-load-balancers
195
+
196
+ cloud_tools/aws/elbv2.py:describe_target_group
197
+ -> impose aws elbv2 describe-target-group
198
+
199
+ cloud_tools/aws/rds.py:reboot_instance
200
+ -> impose aws rds reboot-instance
201
+
202
+ cloud_tools/aws/s3.py:list_buckets
203
+ -> impose aws s3 list-buckets
204
+
205
+ cloud_tools/github/repos.py:archive_repo
206
+ -> impose github repos archive-repo
207
+ ```
208
+
209
+ If you only want folders/packages to create command groups, disable module-generated subcommands:
210
+
211
+ ```python
212
+ # cloud_tools/cli.py
213
+ import cloud_tools
214
+ from impose import ImposeApplication
215
+
216
+
217
+ application = ImposeApplication(
218
+ root_module=cloud_tools,
219
+ use_project_structure=True,
220
+ modules_as_subcommands=False,
221
+ )
222
+ cli = application.cli()
223
+ ```
224
+
225
+ With `modules_as_subcommands=False`, the folder still creates the `aws` group, but `rds.py`, `s3.py`, and `elbv2.py` do not add another command level:
226
+
227
+ ```sh
228
+ impose aws list-load-balancers us-east-1
229
+ impose aws describe-target-group arn:aws:...
230
+ impose aws reboot-instance prod-db-1 --force-failover
231
+ impose aws list-buckets --profile prod
232
+ impose github archive-repo example old-service
233
+ ```
234
+
235
+ ## FastAPI
236
+
237
+ Impose can create a FastAPI application or router from decorated functions that explicitly opt into API exposure. The generated API can be mounted into another FastAPI application, which lets you expose project functionality without building a separate API layer by hand.
238
+
239
+ ### Example: Mounting into FastAPI
240
+
241
+ ```python
242
+ # api.py
243
+ import cloud_tools
244
+ from fastapi import FastAPI
245
+ from impose import ImposeApplication
246
+
247
+
248
+ service = FastAPI()
249
+
250
+ impose_application = ImposeApplication(
251
+ root_module=cloud_tools,
252
+ use_project_structure=True,
253
+ )
254
+
255
+ service.include_router(
256
+ impose_application.api_router(),
257
+ prefix="/internal/tools",
258
+ )
259
+ ```
260
+
261
+ Or create a standalone FastAPI app directly:
262
+
263
+ ```python
264
+ app = impose_application.api_app(title="Internal Tools")
265
+ ```
266
+
267
+ A decorated function such as:
268
+
269
+ ```python
270
+ @impose(impose_cs="elbv2", impose_api_method="GET")
271
+ def list_load_balancers(region: str) -> list[str]:
272
+ ...
273
+ ```
274
+
275
+ can be exposed as an endpoint under the generated router:
276
+
277
+ ```text
278
+ GET /internal/tools/elbv2/list-load-balancers?region=us-east-1
279
+ ```
280
+
281
+ If a function is decorated with `@impose` but does not set `impose_api_method`, it remains available to the CLI but is skipped when `api_router()` or `api_app()` is built. Impose emits a warning for each skipped function so accidental API omissions are visible during app startup.
282
+
283
+ Supported API methods are `GET`, `PUT`, `POST`, `PATCH`, and `DELETE`. Any other `impose_api_method` value fails loudly when the API app or router is created.
284
+
285
+ ### API Parameter Rules
286
+
287
+ `GET` endpoints read function parameters from query parameters:
288
+
289
+ ```python
290
+ @impose(impose_api_method="GET")
291
+ def list_users(tier: str, limit: int = 100) -> list[str]:
292
+ ...
293
+ ```
294
+
295
+ ```text
296
+ GET /list-users?tier=paid&limit=25
297
+ ```
298
+
299
+ `DELETE` endpoints read function parameters from path parameters. Impose appends the function parameters to the route path:
300
+
301
+ ```python
302
+ @impose(impose_api_method="DELETE")
303
+ def delete_user(user_id: str, hard: bool) -> str:
304
+ ...
305
+ ```
306
+
307
+ ```text
308
+ DELETE /delete-user/{user_id}/{hard}
309
+ ```
310
+
311
+ `PUT`, `POST`, and `PATCH` endpoints read every function parameter from the JSON request body:
312
+
313
+ ```python
314
+ from pydantic import BaseModel
315
+
316
+
317
+ class Region(BaseModel):
318
+ partition: str
319
+ name: str
320
+
321
+
322
+ @impose(impose_api_method="POST")
323
+ def list_instances(region: Region, owner: str) -> list[str]:
324
+ ...
325
+ ```
326
+
327
+ The body nests Pydantic models under their parameter names:
328
+
329
+ ```json
330
+ {
331
+ "region": {
332
+ "partition": "aws",
333
+ "name": "us-east-1"
334
+ },
335
+ "owner": "platform"
336
+ }
337
+ ```
338
+
339
+ `GET` and `DELETE` only support primitive-like arguments: `str`, `int`, `float`, `bool`, enums, literals of primitive values, and optional unions of those types. Pydantic models and other structured types are rejected for `GET` and `DELETE`; Impose raises an error during API app/router creation instead of creating an invalid route.
340
+
341
+ ## Middleware
342
+
343
+ Impose supports FastAPI-style HTTP middleware on the whole generated API, on command sets, and on individual endpoints. Middleware runs for generated API routes only; CLI calls execute the Python function directly.
344
+
345
+ Middleware functions receive a request and `call_next`, matching FastAPI's `@app.middleware("http")` style:
346
+
347
+ ```python
348
+ from fastapi import HTTPException, Request, Response
349
+
350
+ from impose import impose
351
+
352
+
353
+ async def require_admin_scope(request: Request, call_next) -> Response:
354
+ scopes = set(request.headers.get("x-auth-user-scopes", "").split())
355
+ if "admin" not in scopes:
356
+ raise HTTPException(status_code=403, detail="Missing admin scope.")
357
+ return await call_next(request)
358
+
359
+
360
+ @impose(
361
+ impose_cs="deploy",
362
+ impose_api_method="POST",
363
+ impose_middleware=[require_admin_scope],
364
+ )
365
+ def restart_service(name: str, environment: str) -> str:
366
+ ...
367
+ ```
368
+
369
+ Add middleware to every generated endpoint:
370
+
371
+ ```python
372
+ from impose import ImposeApplication
373
+
374
+
375
+ application = ImposeApplication()
376
+ application.add_middleware(require_admin_scope)
377
+ ```
378
+
379
+ This works whether you call `application.api_app()` or include `application.api_router()` in another FastAPI app. In router mode, app-wide middleware means every generated Impose endpoint in that router; it does not become global middleware for unrelated routes in the parent app.
380
+
381
+ Add middleware to one command set:
382
+
383
+ ```python
384
+ application.add_command_set_middleware(
385
+ "admin",
386
+ require_admin_scope,
387
+ )
388
+ ```
389
+
390
+ Every generated API endpoint in the `admin` command set will require the admin scope.
391
+
392
+ ## Confirmation Prompts
393
+
394
+ Commands that need an explicit safety check can opt into confirmation:
395
+
396
+ ```python
397
+ from impose import impose
398
+
399
+
400
+ @impose(impose_require_confirmation=True)
401
+ def delete_user(username: str, reason: str) -> None:
402
+ ...
403
+ ```
404
+
405
+ After all CLI arguments have been parsed, including any values collected through `-i` interactive mode, Impose prints an alarming confirmation message with a table of the exact function arguments. Long values are truncated instead of wrapped. The command only runs when the user types `yes`; typing `no` aborts it.
406
+
407
+ ## Tests
408
+
409
+ Run unit tests with 100% coverage enforcement:
410
+
411
+ ```sh
412
+ uv run pytest unit
413
+ ```
414
+
415
+ Run admin-tool integration tests without coverage enforcement:
416
+
417
+ ```sh
418
+ uv run pytest integration
419
+ ```
420
+
421
+ ## Serialization
422
+
423
+ Custom serializers can be attached to types expected by imposed functions using Pydantic. This lets your CLI and API share the same typed interface while still accepting and returning rich project-specific objects.
424
+
425
+ ### Example: Custom Serialization with Pydantic
426
+
427
+ ```python
428
+ from pydantic import BaseModel, field_serializer
429
+
430
+ from impose import impose
431
+
432
+
433
+ class Region(BaseModel):
434
+ partition: str
435
+ name: str
436
+
437
+ @field_serializer("name")
438
+ def serialize_name(self, value: str) -> str:
439
+ return value.lower()
440
+
441
+
442
+ @impose(impose_cs="ec2")
443
+ def list_instances(region: Region) -> list[str]:
444
+ ...
445
+ ```
446
+
447
+ Invoke it from the CLI by passing the Pydantic model as structured input:
448
+
449
+ ```sh
450
+ impose ec2 list-instances '{"partition":"aws","name":"US-EAST-1"}'
451
+ ```
452
+
453
+ The `field_serializer` normalizes the region name when the value is serialized, so downstream CLI output or API responses can return `us-east-1`.
454
+
455
+ If the same function is exposed through FastAPI, the generated endpoint can accept the same shape in the request body:
456
+
457
+ ```sh
458
+ curl -X POST http://localhost:8000/ec2/list-instances \
459
+ -H 'content-type: application/json' \
460
+ -d '{"region":{"partition":"aws","name":"US-EAST-1"}}'
461
+ ```
462
+
463
+ The same type can be used by the CLI parser and the generated API schema, so project-specific values do not need separate CLI and HTTP representations.
464
+
465
+ ### Example: Rich Return Types
466
+
467
+ ```python
468
+ from pydantic import BaseModel
469
+
470
+ from impose import impose
471
+
472
+
473
+ class LoadBalancer(BaseModel):
474
+ name: str
475
+ arn: str
476
+ scheme: str
477
+
478
+
479
+ @impose(impose_cs="elbv2")
480
+ def get_load_balancer(name: str) -> LoadBalancer:
481
+ ...
482
+ ```
483
+
484
+ When exposed through the API, the Pydantic model becomes the response schema. When used from the CLI, Impose can serialize the result into a stable command output format.
485
+
486
+ ## Interactive Mode
487
+
488
+ Impose supports a global `-i` mode that turns your impose command into an interactive command. Enums, Literals, and Booleans become interactive options that can be selected instead of typed manually.
489
+
490
+ ### Example: Interactive Options
491
+
492
+ ```python
493
+ from enum import Enum
494
+ from typing import Literal
495
+
496
+ from impose import impose
497
+
498
+
499
+ class Environment(str, Enum):
500
+ dev = "dev"
501
+ staging = "staging"
502
+ prod = "prod"
503
+
504
+
505
+ @impose(impose_cs="deploy")
506
+ def deploy_service(
507
+ service: str,
508
+ environment: Environment,
509
+ strategy: Literal["rolling", "blue-green"],
510
+ dry_run: bool = True,
511
+ ) -> None:
512
+ ...
513
+ ```
514
+
515
+ Run the command normally:
516
+
517
+ ```sh
518
+ impose deploy deploy-service \
519
+ billing \
520
+ prod \
521
+ rolling \
522
+ --dry-run
523
+ ```
524
+
525
+ Or use interactive mode:
526
+
527
+ ```sh
528
+ impose -i deploy deploy-service
529
+ ```
530
+
531
+ In interactive mode, Impose can prompt for `environment`, `strategy`, and `dry_run` using selectable choices.
532
+
533
+ ## Development
534
+
535
+ Enter the development environment:
536
+
537
+ ```sh
538
+ direnv allow
539
+ ```
540
+
541
+ Install the project environment:
542
+
543
+ ```sh
544
+ uv sync
545
+ ```
546
+
547
+ Run the CLI:
548
+
549
+ ```sh
550
+ uv run impose --help
551
+ ```