pdt-cli 0.1.1__tar.gz → 0.1.2__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 (93) hide show
  1. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/PKG-INFO +20 -5
  2. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/README.md +18 -4
  3. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/ci/tests/test_claude_task.py +3 -0
  4. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/ci/tests/test_score_task.py +109 -102
  5. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/pyproject.toml +3 -2
  6. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/cli.py +47 -17
  7. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/completion.py +0 -26
  8. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/config.py +56 -9
  9. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/console.py +13 -0
  10. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/deploy.py +31 -12
  11. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/deploy_aws.py +50 -28
  12. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/deploy_aws_fargate.py +37 -9
  13. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/deploy_azure.py +206 -51
  14. pdt_cli-0.1.2/src/pdt/deploy_azure_container_apps.py +676 -0
  15. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/deploy_common.py +155 -4
  16. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/deploy_google_cloud.py +64 -34
  17. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/deploy_windows.py +5 -2
  18. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/gcloud_sdk.py +5 -13
  19. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/utils/email_auth.py +4 -14
  20. pdt_cli-0.1.2/src/pdt/utils/env_secret.py +192 -0
  21. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/conftest.py +7 -0
  22. pdt_cli-0.1.2/tests/test_aws_inventory.py +38 -0
  23. pdt_cli-0.1.2/tests/test_aws_names.py +9 -0
  24. pdt_cli-0.1.2/tests/test_aws_not_found.py +19 -0
  25. pdt_cli-0.1.2/tests/test_aws_profile.py +16 -0
  26. pdt_cli-0.1.2/tests/test_azure_build.py +62 -0
  27. pdt_cli-0.1.2/tests/test_azure_deleted_vault.py +54 -0
  28. pdt_cli-0.1.2/tests/test_azure_environment.py +210 -0
  29. pdt_cli-0.1.2/tests/test_azure_environment_key.py +23 -0
  30. pdt_cli-0.1.2/tests/test_azure_group_ownership.py +21 -0
  31. pdt_cli-0.1.2/tests/test_azure_job_diagnostics.py +132 -0
  32. pdt_cli-0.1.2/tests/test_azure_names.py +24 -0
  33. pdt_cli-0.1.2/tests/test_azure_retries.py +113 -0
  34. pdt_cli-0.1.2/tests/test_azure_side_resources.py +39 -0
  35. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_azure_storage.py +19 -0
  36. pdt_cli-0.1.2/tests/test_azure_subscription_state.py +29 -0
  37. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_ci_azure.py +8 -0
  38. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_ci_google_cloud.py +0 -17
  39. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_cli.py +15 -4
  40. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_completion.py +0 -12
  41. pdt_cli-0.1.2/tests/test_env_secret.py +142 -0
  42. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_env_sources.py +52 -1
  43. pdt_cli-0.1.2/tests/test_gcloud_sdk.py +24 -0
  44. pdt_cli-0.1.2/tests/test_google_cloud_inventory.py +23 -0
  45. pdt_cli-0.1.2/tests/test_google_cloud_not_found.py +13 -0
  46. pdt_cli-0.1.2/tests/test_google_cloud_retries.py +41 -0
  47. pdt_cli-0.1.2/tests/test_google_cloud_service_account.py +24 -0
  48. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_project_discovery.py +7 -0
  49. pdt_cli-0.1.2/tests/test_secrets.py +110 -0
  50. pdt_cli-0.1.2/tests/test_verify.py +277 -0
  51. pdt_cli-0.1.2/verify/README.md +92 -0
  52. pdt_cli-0.1.1/src/pdt/deploy_azure_container_apps.py +0 -402
  53. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/.gitignore +0 -0
  54. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/ci/tests/test_close_stale_drafts.py +0 -0
  55. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/ci/tests/test_rebase_task.py +0 -0
  56. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/__init__.py +0 -0
  57. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/examples/hello-world/config.yml +0 -0
  58. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/examples/hello-world/run.py +0 -0
  59. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/examples/impossible-travel-report/config.yml +0 -0
  60. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/examples/impossible-travel-report/env.template +0 -0
  61. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/examples/impossible-travel-report/run.py +0 -0
  62. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/examples/monday-orphaned-account-report/config.yml +0 -0
  63. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/examples/monday-orphaned-account-report/env.template +0 -0
  64. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/examples/monday-orphaned-account-report/run.py +0 -0
  65. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/scaffold.py +0 -0
  66. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/storage_cli.py +0 -0
  67. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/utils/__init__.py +0 -0
  68. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/utils/entra.py +0 -0
  69. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/utils/log.py +0 -0
  70. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/utils/send_email.py +0 -0
  71. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/utils/send_email_graph.py +0 -0
  72. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/utils/storage.py +0 -0
  73. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/src/pdt/utils/web.py +0 -0
  74. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_aws_account.py +0 -0
  75. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_aws_storage.py +0 -0
  76. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_config_merge.py +0 -0
  77. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_console.py +0 -0
  78. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_cost_estimate.py +0 -0
  79. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_deploy_state.py +0 -0
  80. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_dockerfile.py +0 -0
  81. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_fetch_json.py +0 -0
  82. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_google_cloud_storage.py +0 -0
  83. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_init_questions.py +0 -0
  84. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_log_colour.py +0 -0
  85. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_run_log_links.py +0 -0
  86. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_save_platform_key.py +0 -0
  87. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_scaffold.py +0 -0
  88. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_schedule.py +0 -0
  89. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_starter_app.py +0 -0
  90. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_storage.py +0 -0
  91. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_storage_cli.py +0 -0
  92. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_strict_validation.py +0 -0
  93. {pdt_cli-0.1.1 → pdt_cli-0.1.2}/tests/test_windows_storage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pdt-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Easily deploy your process to AWS, Azure, Google Cloud, or Windows.
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.12
@@ -17,6 +17,7 @@ Requires-Dist: shellingham
17
17
  Provides-Extra: apps
18
18
  Requires-Dist: adlfs; extra == 'apps'
19
19
  Requires-Dist: azure-identity; extra == 'apps'
20
+ Requires-Dist: azure-keyvault-secrets; extra == 'apps'
20
21
  Requires-Dist: azure-storage-blob; extra == 'apps'
21
22
  Requires-Dist: boto3; extra == 'apps'
22
23
  Requires-Dist: cryptography; extra == 'apps'
@@ -40,7 +41,7 @@ uv tool install pdt-cli
40
41
 
41
42
  That puts a `pdt` command on your PATH. To update it later, run `uv tool upgrade pdt-cli`.
42
43
 
43
- The first interactive `pdt` command turns on tab completion without a prompt. It works in every new terminal: Tab finishes pdt commands, their options, your app names, example names for `new --from`, and AWS profile names for `--profile`. It supports bash, zsh, fish, and PowerShell. `pdt completion [SHELL]` sets up a second shell, and `pdt completion --script bash` prints the script instead of writing it, if you keep your own dotfiles.
44
+ The first interactive `pdt` command turns on tab completion without a prompt. It works in every new terminal: Tab finishes pdt commands, their options, your app names, and example names for `new --from`. It supports bash, zsh, fish, and PowerShell. `pdt completion [SHELL]` sets up a second shell, and `pdt completion --script bash` prints the script instead of writing it, if you keep your own dotfiles.
44
45
 
45
46
  You can also clone this repository and run `./pdt` (or `.\pdt.bat` on Windows) instead. It installs `uv` for you if you do not have it. Both ways give you the same commands.
46
47
 
@@ -116,18 +117,22 @@ pdt login my-report
116
117
  | `pdt init [DIR]` | create a project here, or in DIR |
117
118
  | `pdt examples` | list the example apps bundled with pdt |
118
119
  | `pdt new APP --from EXAMPLE` | add an app to the project |
119
- | `pdt list` | show every app with its schedule and provider |
120
+ | `pdt list` | show every app with its schedule and provider; `--names` prints only the enabled app names |
120
121
  | `pdt validate` | check the config files and the required env vars |
121
122
  | `pdt run APP` | run an app on this machine |
122
123
  | `pdt deploy APP` | deploy an app to its configured platform |
123
124
  | `pdt destroy APP` | remove everything deploy created |
125
+ | `pdt secrets APP` | show which `.env` values differ from the deployed app |
126
+ | `pdt secrets APP save` | send your `.env` values to the deployed app; the next run uses them |
127
+ | `pdt secrets APP get` | copy the deployed values into a `.env.<provider>` file |
128
+ | `pdt secrets APP set NAME` | put one value, read from stdin, into the deployed app's secrets |
124
129
  | `pdt login APP` | sign in again to the app's cloud provider |
125
130
  | `pdt storage APP ls|get|query|destroy` | look at, fetch, query, or delete the app's stored files |
126
131
  | `pdt az ...` | run the Azure CLI that pdt installs |
127
132
  | `pdt gcloud ...` | run the Google Cloud CLI that pdt installs |
128
133
  | `pdt completion [SHELL]` | turn on tab completion for a shell |
129
134
 
130
- Leave `APP` off `run`, `deploy`, `destroy`, or `login`, or mistype it, and pdt lists the apps in the project so you can pick one.
135
+ Leave `APP` off `run`, `deploy`, `destroy`, `secrets`, or `login`, or mistype it, and pdt lists the apps in the project so you can pick one.
131
136
 
132
137
  `pdt az` and `pdt gcloud` hand your arguments straight to the cloud tool, and install it first if it is missing. For example, `pdt az account list`.
133
138
 
@@ -165,6 +170,8 @@ store.push(Path(".pdt-state"), "state/", lease)
165
170
 
166
171
  From your own computer, `pdt storage APP ls`, `get`, and `query` read the files with your own cloud sign-in. `pdt storage APP destroy` is the only command that deletes them, and it asks first. An app that needs none of this sets `storage: false` in its `config.yml`.
167
172
 
173
+ An app that is not ready sets `enabled: false` in its `config.yml`. `pdt list` still shows it, and every other command acts as if the app is not there. `uv run run.py` in the app folder still runs it.
174
+
168
175
  ## Running pdt from CI
169
176
 
170
177
  Every command reads its values from the environment it runs in. A build server sets them as CI variables, and no `.env` file is needed.
@@ -186,10 +193,14 @@ platform:
186
193
  subscription: 00000000-0000-0000-0000-000000000000
187
194
  region: eastus
188
195
  resource_group: pdt
196
+ # Optional. An environment you already own, as <resource-group>/<name>.
197
+ # environment: my-group/my-environment
189
198
  ```
190
199
 
191
200
  `subscription` is optional. When it is missing or wrong, the deploy asks you to choose one.
192
201
 
202
+ Every pdt project in a subscription runs its jobs in one shared Container Apps environment per region, `pdt-<region>` in the resource group `pdt-shared`, because a subscription allows only a few environments. Destroying the last app that uses the environment removes it, and removes `pdt-shared` once it holds no environment. Set `environment` to use an environment you already have; pdt then never creates, changes, or deletes it.
203
+
193
204
  ### AWS
194
205
 
195
206
  ```yaml
@@ -198,8 +209,12 @@ platform:
198
209
  region: us-east-1
199
210
  # Written for you on the first deploy, from your credentials.
200
211
  account: "123456789012"
212
+ # Optional. Which profile in ~/.aws to use. Written for you if pdt has to ask.
213
+ profile: work
201
214
  ```
202
215
 
216
+ `profile` is optional. When it is missing, pdt uses `AWS_PROFILE`, or the only profile on your computer. When there are several and none is chosen, or the one in the file is not on this computer, the deploy lists them and asks you to choose, then writes your answer here.
217
+
203
218
  ### Google Cloud
204
219
 
205
220
  ```yaml
@@ -289,7 +304,7 @@ An app is a folder with a `run.py` that has a `main()` function. It declares its
289
304
  ```python
290
305
  # /// script
291
306
  # requires-python = ">=3.12"
292
- # dependencies = ["pdt-cli[apps]==0.1.0"]
307
+ # dependencies = ["pdt-cli[apps]==0.1.2"]
293
308
  # ///
294
309
  from pathlib import Path
295
310
 
@@ -10,7 +10,7 @@ uv tool install pdt-cli
10
10
 
11
11
  That puts a `pdt` command on your PATH. To update it later, run `uv tool upgrade pdt-cli`.
12
12
 
13
- The first interactive `pdt` command turns on tab completion without a prompt. It works in every new terminal: Tab finishes pdt commands, their options, your app names, example names for `new --from`, and AWS profile names for `--profile`. It supports bash, zsh, fish, and PowerShell. `pdt completion [SHELL]` sets up a second shell, and `pdt completion --script bash` prints the script instead of writing it, if you keep your own dotfiles.
13
+ The first interactive `pdt` command turns on tab completion without a prompt. It works in every new terminal: Tab finishes pdt commands, their options, your app names, and example names for `new --from`. It supports bash, zsh, fish, and PowerShell. `pdt completion [SHELL]` sets up a second shell, and `pdt completion --script bash` prints the script instead of writing it, if you keep your own dotfiles.
14
14
 
15
15
  You can also clone this repository and run `./pdt` (or `.\pdt.bat` on Windows) instead. It installs `uv` for you if you do not have it. Both ways give you the same commands.
16
16
 
@@ -86,18 +86,22 @@ pdt login my-report
86
86
  | `pdt init [DIR]` | create a project here, or in DIR |
87
87
  | `pdt examples` | list the example apps bundled with pdt |
88
88
  | `pdt new APP --from EXAMPLE` | add an app to the project |
89
- | `pdt list` | show every app with its schedule and provider |
89
+ | `pdt list` | show every app with its schedule and provider; `--names` prints only the enabled app names |
90
90
  | `pdt validate` | check the config files and the required env vars |
91
91
  | `pdt run APP` | run an app on this machine |
92
92
  | `pdt deploy APP` | deploy an app to its configured platform |
93
93
  | `pdt destroy APP` | remove everything deploy created |
94
+ | `pdt secrets APP` | show which `.env` values differ from the deployed app |
95
+ | `pdt secrets APP save` | send your `.env` values to the deployed app; the next run uses them |
96
+ | `pdt secrets APP get` | copy the deployed values into a `.env.<provider>` file |
97
+ | `pdt secrets APP set NAME` | put one value, read from stdin, into the deployed app's secrets |
94
98
  | `pdt login APP` | sign in again to the app's cloud provider |
95
99
  | `pdt storage APP ls|get|query|destroy` | look at, fetch, query, or delete the app's stored files |
96
100
  | `pdt az ...` | run the Azure CLI that pdt installs |
97
101
  | `pdt gcloud ...` | run the Google Cloud CLI that pdt installs |
98
102
  | `pdt completion [SHELL]` | turn on tab completion for a shell |
99
103
 
100
- Leave `APP` off `run`, `deploy`, `destroy`, or `login`, or mistype it, and pdt lists the apps in the project so you can pick one.
104
+ Leave `APP` off `run`, `deploy`, `destroy`, `secrets`, or `login`, or mistype it, and pdt lists the apps in the project so you can pick one.
101
105
 
102
106
  `pdt az` and `pdt gcloud` hand your arguments straight to the cloud tool, and install it first if it is missing. For example, `pdt az account list`.
103
107
 
@@ -135,6 +139,8 @@ store.push(Path(".pdt-state"), "state/", lease)
135
139
 
136
140
  From your own computer, `pdt storage APP ls`, `get`, and `query` read the files with your own cloud sign-in. `pdt storage APP destroy` is the only command that deletes them, and it asks first. An app that needs none of this sets `storage: false` in its `config.yml`.
137
141
 
142
+ An app that is not ready sets `enabled: false` in its `config.yml`. `pdt list` still shows it, and every other command acts as if the app is not there. `uv run run.py` in the app folder still runs it.
143
+
138
144
  ## Running pdt from CI
139
145
 
140
146
  Every command reads its values from the environment it runs in. A build server sets them as CI variables, and no `.env` file is needed.
@@ -156,10 +162,14 @@ platform:
156
162
  subscription: 00000000-0000-0000-0000-000000000000
157
163
  region: eastus
158
164
  resource_group: pdt
165
+ # Optional. An environment you already own, as <resource-group>/<name>.
166
+ # environment: my-group/my-environment
159
167
  ```
160
168
 
161
169
  `subscription` is optional. When it is missing or wrong, the deploy asks you to choose one.
162
170
 
171
+ Every pdt project in a subscription runs its jobs in one shared Container Apps environment per region, `pdt-<region>` in the resource group `pdt-shared`, because a subscription allows only a few environments. Destroying the last app that uses the environment removes it, and removes `pdt-shared` once it holds no environment. Set `environment` to use an environment you already have; pdt then never creates, changes, or deletes it.
172
+
163
173
  ### AWS
164
174
 
165
175
  ```yaml
@@ -168,8 +178,12 @@ platform:
168
178
  region: us-east-1
169
179
  # Written for you on the first deploy, from your credentials.
170
180
  account: "123456789012"
181
+ # Optional. Which profile in ~/.aws to use. Written for you if pdt has to ask.
182
+ profile: work
171
183
  ```
172
184
 
185
+ `profile` is optional. When it is missing, pdt uses `AWS_PROFILE`, or the only profile on your computer. When there are several and none is chosen, or the one in the file is not on this computer, the deploy lists them and asks you to choose, then writes your answer here.
186
+
173
187
  ### Google Cloud
174
188
 
175
189
  ```yaml
@@ -259,7 +273,7 @@ An app is a folder with a `run.py` that has a `main()` function. It declares its
259
273
  ```python
260
274
  # /// script
261
275
  # requires-python = ">=3.12"
262
- # dependencies = ["pdt-cli[apps]==0.1.0"]
276
+ # dependencies = ["pdt-cli[apps]==0.1.2"]
263
277
  # ///
264
278
  from pathlib import Path
265
279
 
@@ -204,6 +204,9 @@ def test_claude_command_shape(tmp_path):
204
204
  assert command[command.index("--disallowedTools") + 1] == "Bash(git push -f*)"
205
205
  assert command[command.index("--max-budget-usd") + 1] == "20.0"
206
206
  assert "--append-system-prompt-file" not in command
207
+ assert "--effort" not in command
208
+ high = runner.claude_command(runner.load_task("demo", make_task(tmp_path / "e", "effort: high\n")))
209
+ assert high[high.index("--effort") + 1] == "high"
207
210
  assert runner.claude_command(task, 0.1)[runner.claude_command(task, 0.1).index(
208
211
  "--max-budget-usd") + 1] == "0.1"
209
212
 
@@ -136,6 +136,21 @@ def test_a_simple_floor_says_why():
136
136
  "simple", ["only documentation, tests, or example apps changed"])
137
137
 
138
138
 
139
+ def test_a_few_tested_lines_in_a_covered_file_floor_at_simple():
140
+ small = change("src/pdt/deploy_common.py", lines=4)
141
+ test = change("tests/test_dockerfile.py")
142
+ floor, reasons = select.rule_floor(mr(), [small, test])
143
+ assert floor == "simple" and "src/pdt/deploy_common.py" in reasons[0]
144
+ assert select.rule_floor(mr(), [small])[0] == "architectural"
145
+ assert select.rule_floor(mr(), [small, change("ci/tests/test_x.py")])[0] == "architectural"
146
+ five = change("src/pdt/deploy_common.py", lines=5)
147
+ assert select.rule_floor(mr(), [five, test])[0] == "architectural"
148
+ gated = change("src/pdt/cli.py", extra="+token = 1\n")
149
+ assert select.rule_floor(mr(), [gated, test])[0] == "architectural"
150
+ for path in ("src/pdt/utils/log.py", ".gitlab-ci.yml", "AGENTS.md", "pdt"):
151
+ assert select.rule_floor(mr(), [change(path), test])[0] == "architectural", path
152
+
153
+
139
154
  def test_deleted_renamed_and_large_files_bump_simple_to_review():
140
155
  for flag in ("deleted_file", "renamed_file", "too_large", "collapsed"):
141
156
  floor, reasons = select.rule_floor(mr(), [change("README.md", **{flag: True})])
@@ -265,6 +280,9 @@ def test_status_and_note_body():
265
280
  assert body.splitlines()[0] == check.format_marker("abc", "review", "architectural", "raised")
266
281
  assert "`tier::architectural`" in body and "Claude raised it" in body
267
282
  assert "- touches deploy dispatch" in body and "http://job" in body
283
+ assert check.MERGE_FOOTER not in body and check.FOOTER in body
284
+ simple = check.note_body("abc", check.Score("simple", "simple", [], "agree"), "")
285
+ assert check.MERGE_FOOTER in simple
268
286
 
269
287
 
270
288
  def test_label_change_leaves_exactly_one_tier_label():
@@ -276,16 +294,6 @@ def test_label_change_leaves_exactly_one_tier_label():
276
294
 
277
295
  # --- select hook end to end ---------------------------------------------------
278
296
 
279
- def webhook_event(tmp_path, monkeypatch, iid, action="open", changes=None) -> None:
280
- """Point TRIGGER_PAYLOAD at a merge request webhook payload for ``iid``."""
281
- payload = {"object_kind": "merge_request", "event_type": "merge_request",
282
- "object_attributes": {"iid": iid, "action": action},
283
- "changes": changes or {}}
284
- path = tmp_path / "payload.json"
285
- path.write_text(json.dumps(payload))
286
- monkeypatch.setenv("TRIGGER_PAYLOAD", str(path))
287
-
288
-
289
297
  def run_select(monkeypatch, capsys, mrs, notes, dry_run="false"):
290
298
  arm(monkeypatch, dry_run)
291
299
  fetches = []
@@ -389,61 +397,8 @@ def test_select_names_missing_variables(monkeypatch, capsys):
389
397
 
390
398
  # --- a webhook run looks at one MR --------------------------------------------
391
399
 
392
- def test_event_scope_names_the_mr_or_says_there_is_nothing_to_do():
393
- def event(action, changes=None, **attrs):
394
- return {"object_kind": "merge_request",
395
- "object_attributes": {"iid": 5, "action": action, **attrs},
396
- "changes": changes or {}}
397
-
398
- assert select.event_scope(None) == ("all", None)
399
- assert select.event_scope({"object_kind": "push"}) == ("all", None)
400
- assert select.event_scope({"object_kind": "merge_request", "object_attributes": {}}) == (
401
- "all", None)
402
- ready = {"draft": {"previous": True, "current": False}, "title": {}, "updated_at": {}}
403
- assert select.event_scope(event("open")) == ("one", 5)
404
- assert select.event_scope(event("reopen")) == ("one", 5)
405
- assert select.event_scope(event("update", ready)) == ("one", 5)
406
- assert select.event_scope(event("update", {"work_in_progress": ready["draft"]})) == ("one", 5)
407
- # Everything else does nothing: pushes, rebases, labels, edits, going to Draft, closing.
408
- assert select.event_scope(event("update", {"last_commit": {}, "updated_at": {}})) == ("none", 5)
409
- assert select.event_scope(event("update", {"labels": {}, "updated_at": {}})) == ("none", 5)
410
- assert select.event_scope(event("update", {"draft": {"previous": False, "current": True}})) == (
411
- "none", 5)
412
- assert select.event_scope(event("update", {"description": {}})) == ("none", 5)
413
- assert select.event_scope(event("update")) == ("none", 5)
414
- assert select.event_scope(event("approved")) == ("none", 5)
415
- assert select.event_scope(event("close")) == ("none", 5)
416
- assert select.event_scope(event("merge")) == ("none", 5)
417
-
418
-
419
- def test_gate_says_skip_for_an_event_that_does_nothing_and_run_otherwise(monkeypatch, capsys, tmp_path):
420
- monkeypatch.delenv("TRIGGER_PAYLOAD", raising=False)
421
- assert select.main(["--gate"]) == 0
422
- assert capsys.readouterr().out.strip() == "run"
423
- webhook_event(tmp_path, monkeypatch, 4, action="update", changes={"last_commit": {}})
424
- assert select.main(["--gate"]) == 0
425
- out, err = capsys.readouterr()
426
- assert out.strip() == "skip" and "webhook event for !4 is 'update'" in err
427
- webhook_event(tmp_path, monkeypatch, 4, action="update",
428
- changes={"draft": {"previous": True, "current": False}})
429
- assert select.main(["--gate"]) == 0
430
- assert capsys.readouterr().out.strip() == "run"
431
- webhook_event(tmp_path, monkeypatch, 4)
432
- assert select.main(["--gate"]) == 0
433
- assert capsys.readouterr().out.strip() == "run"
434
-
435
-
436
- def test_the_ci_job_gates_on_the_event_before_installing_anything():
437
- ci = yaml.safe_load((REPO / ".gitlab-ci.yml").read_text())
438
- gate = ci[".claude-task"]["before_script"][0]
439
- assert gate.startswith("if [ -n \"$CLAUDE_TASK_GATE\" ]") and "= skip" in gate and "exit 0" in gate
440
- assert ci["score-mrs"]["variables"]["CLAUDE_TASK_GATE"] == \
441
- "python ci/claude-tasks/score-mrs/select_mrs.py --gate"
442
- assert "CLAUDE_TASK_GATE" not in ci["rebase-mrs"].get("variables", {})
443
-
444
-
445
- def test_a_webhook_run_scores_only_the_mr_in_the_event(monkeypatch, capsys, tmp_path):
446
- webhook_event(tmp_path, monkeypatch, 2)
400
+ def test_a_webhook_run_scores_only_the_mr_in_the_event(monkeypatch, capsys):
401
+ monkeypatch.setenv("MR_IID", "2")
447
402
  mrs = {1: {**mr(1), "_files": [change("docs/a.md")]},
448
403
  2: {**mr(2), "_files": [change("docs/b.md")]},
449
404
  3: {**mr(3), "_files": [change("docs/c.md")]}}
@@ -452,53 +407,103 @@ def test_a_webhook_run_scores_only_the_mr_in_the_event(monkeypatch, capsys, tmp_
452
407
  assert "webhook event for !2: looking at that MR only" in err
453
408
  assert "1 open MR(s)" in err
454
409
  touched = {p for _, p, _ in calls if "/merge_requests/" in p}
455
- assert touched == {"/projects/42/merge_requests/2", "/projects/42/merge_requests/2/diffs?per_page=100&page=1",
456
- "/projects/42/merge_requests/2/notes?sort=desc&order_by=created_at&per_page=100&page=1"}
410
+ assert touched == {"/projects/42/merge_requests/2",
411
+ "/projects/42/merge_requests/2/diffs?per_page=100&page=1",
412
+ "/projects/42/merge_requests/2/notes?sort=desc&order_by=created_at"
413
+ "&per_page=100&page=1"}
457
414
  assert fetches == [["git", "fetch", "--quiet", "origin",
458
415
  "+refs/heads/master:refs/remotes/origin/master",
459
416
  "+refs/heads/branch-2:refs/remotes/origin/branch-2"]]
460
417
 
461
418
 
462
- def test_a_webhook_run_for_an_mr_leaving_draft_scores_it(monkeypatch, capsys, tmp_path):
463
- webhook_event(tmp_path, monkeypatch, 2, action="update",
464
- changes={"draft": {"previous": True, "current": False},
465
- "title": {"previous": "Draft: x", "current": "x"}})
419
+ def test_a_webhook_run_for_an_mr_that_is_no_longer_open_lists_nothing(monkeypatch, capsys):
420
+ monkeypatch.setenv("MR_IID", "9")
421
+ items, err, calls, fetches = run_select(monkeypatch, capsys, {1: {**mr(1), "_files": []}}, {})
422
+ assert items == [] and fetches == []
423
+ assert "not an open MR of this project" in err
424
+ assert [p for _, p, _ in calls] == [
425
+ "/projects/42/merge_requests?state=opened&target_branch=master&per_page=100&page=1"]
426
+
427
+
428
+ def test_a_run_without_an_event_sweeps_every_open_mr(monkeypatch, capsys):
429
+ monkeypatch.delenv("MR_IID", raising=False)
466
430
  mrs = {1: {**mr(1), "_files": [change("docs/a.md")]},
467
431
  2: {**mr(2), "_files": [change("docs/b.md")]}}
468
432
  items, err, _, _ = run_select(monkeypatch, capsys, mrs, {})
469
- assert [item["iid"] for item in items] == [2]
470
- assert "looking at that MR only" in err
433
+ assert [item["iid"] for item in items] == [1, 2]
434
+ assert "webhook event" not in err
435
+ monkeypatch.setenv("MR_IID", " ")
436
+ items, _, _, _ = run_select(monkeypatch, capsys, mrs, {})
437
+ assert [item["iid"] for item in items] == [1, 2]
471
438
 
472
439
 
473
- def test_a_webhook_run_for_any_other_event_does_nothing(monkeypatch, capsys, tmp_path):
474
- for action, changes in (("close", None),
475
- ("update", {"last_commit": {"previous": {}, "current": {}}}),
476
- ("update", {"labels": {"previous": [], "current": [{"title": "tier::review"}]},
477
- "updated_at": {"previous": "a", "current": "b"}})):
478
- webhook_event(tmp_path, monkeypatch, 2, action=action, changes=changes)
479
- items, err, calls, fetches = run_select(monkeypatch, capsys,
480
- {2: {**mr(2), "_files": []}}, {})
481
- assert items == [] and calls == [] and fetches == [], action
482
- assert f"webhook event for !2 is '{action}'" in err and "nothing to do" in err
440
+ # --- which events run which job -----------------------------------------------
483
441
 
442
+ def job_runs(job: str, variables: dict) -> bool:
443
+ """Evaluate one job's rules the way GitLab would for these variables.
484
444
 
485
- def test_a_webhook_run_for_an_mr_that_is_no_longer_open_lists_nothing(monkeypatch, capsys, tmp_path):
486
- webhook_event(tmp_path, monkeypatch, 9)
487
- items, err, calls, fetches = run_select(monkeypatch, capsys, {1: {**mr(1), "_files": []}}, {})
488
- assert items == [] and fetches == []
489
- assert "not an open MR of this project" in err
490
- assert [p for _, p, _ in calls] == ["/projects/42/merge_requests?state=opened&target_branch=master&per_page=100&page=1"]
445
+ Only what those rules use is understood: ``$X == "v"``, ``$X != "v"`` and
446
+ ``$X == null``, joined with ``&&``, and a rule with no ``if`` that matches
447
+ everything. The first matching rule decides; no match means the job does
448
+ not run.
449
+ """
450
+ ci = yaml.safe_load((REPO / ".gitlab-ci.yml").read_text())
451
+ for rule in ci[job]["rules"]:
452
+ matched = True
453
+ for clause in rule["if"].split(" && ") if "if" in rule else ():
454
+ if " != " in clause:
455
+ name, _, value = clause.partition(" != ")
456
+ matched &= variables.get(name.strip("$ ")) != value.strip('"')
457
+ else:
458
+ name, _, value = clause.partition(" == ")
459
+ expected = None if value == "null" else value.strip('"')
460
+ matched &= variables.get(name.strip("$ ")) == expected
461
+ if matched:
462
+ return rule.get("when") != "never"
463
+ return False
464
+
465
+
466
+ def trigger(action, before="null", after="null"):
467
+ """The variables the webhook's template sends; a missing Draft change renders as null."""
468
+ return {"CI_PIPELINE_SOURCE": "trigger", "mode": "score_mrs", "MR_IID": "5",
469
+ "MR_ACTION": action, "MR_DRAFT_BEFORE": before, "MR_DRAFT_AFTER": after}
470
+
471
+
472
+ SCORED_EVENTS = [trigger("open"), trigger("reopen"), trigger("update", "true", "false")]
473
+ # Pushes, rebases, labels, edits, going to Draft, approvals, closing, merging.
474
+ SKIPPED_EVENTS = [trigger("update"), trigger("update", "false", "true"),
475
+ trigger("update", "true", "true"), trigger("approved"),
476
+ trigger("close"), trigger("merge"), trigger("")]
477
+
478
+
479
+ def test_a_webhook_event_scores_only_when_the_mr_opens_or_leaves_draft():
480
+ for variables in SCORED_EVENTS:
481
+ assert job_runs("score-mrs", variables), variables
482
+ assert not job_runs("score-mrs-skip", variables), variables
483
+ for variables in SKIPPED_EVENTS:
484
+ assert not job_runs("score-mrs", variables), variables
485
+
486
+
487
+ def test_every_webhook_event_gets_a_pipeline_so_the_hook_never_sees_a_4xx():
488
+ # The trigger API answers 400 when a pipeline has no job or workflow rules
489
+ # filter it out, and GitLab disables a webhook after four 4xx in a row.
490
+ ci = yaml.safe_load((REPO / ".gitlab-ci.yml").read_text())
491
+ assert "workflow" not in ci, "workflow rules would filter trigger pipelines out"
492
+ for variables in SCORED_EVENTS + SKIPPED_EVENTS:
493
+ assert job_runs("score-mrs", variables) != job_runs("score-mrs-skip", variables), variables
491
494
 
492
495
 
493
- def test_a_bad_payload_falls_back_to_a_sweep(monkeypatch, capsys, tmp_path):
494
- (tmp_path / "payload.json").write_text("not json")
495
- monkeypatch.setenv("TRIGGER_PAYLOAD", str(tmp_path / "payload.json"))
496
- items, err, _, _ = run_select(monkeypatch, capsys, {1: {**mr(1), "_files": [change("a.md")]}}, {})
497
- assert [item["iid"] for item in items] == [1]
498
- assert "sweeping every open MR" in err
499
- monkeypatch.setenv("TRIGGER_PAYLOAD", str(tmp_path / "missing.json"))
500
- items, err, _, _ = run_select(monkeypatch, capsys, {1: {**mr(1), "_files": [change("a.md")]}}, {})
501
- assert [item["iid"] for item in items] == [1]
496
+ def test_the_skip_job_never_runs_outside_a_score_mrs_trigger():
497
+ for variables in [{"CI_PIPELINE_SOURCE": "merge_request_event"},
498
+ {"CI_PIPELINE_SOURCE": "push", "CI_COMMIT_BRANCH": "master"},
499
+ {"CI_PIPELINE_SOURCE": "schedule", "mode": "score_mrs"},
500
+ {"CI_PIPELINE_SOURCE": "schedule", "mode": "close_stale_drafts"},
501
+ {"CI_PIPELINE_SOURCE": "web", "mode": "score_mrs"},
502
+ {"CI_PIPELINE_SOURCE": "web"},
503
+ {"CI_PIPELINE_SOURCE": "trigger", "mode": "other"}]:
504
+ assert not job_runs("score-mrs-skip", variables), variables
505
+ assert job_runs("score-mrs", {"CI_PIPELINE_SOURCE": "schedule", "mode": "score_mrs"})
506
+ assert job_runs("score-mrs", {"CI_PIPELINE_SOURCE": "web", "mode": "score_mrs"})
502
507
 
503
508
 
504
509
  # --- check hook end to end ----------------------------------------------------
@@ -613,7 +618,8 @@ def test_score_mrs_job_runs_on_a_webhook_trigger_and_its_schedule():
613
618
  rules = {rule["if"]: rule.get("variables", {}) for rule in job["rules"]}
614
619
  assert not [k for k in rules if "CI_DEFAULT_BRANCH" in k], "no run on every merge"
615
620
  trigger = [v for k, v in rules.items() if '"trigger"' in k and "score_mrs" in k]
616
- assert trigger == [{"DRY_RUN": "false"}]
621
+ assert len(trigger) == 3, "open, reopen, and leaving Draft"
622
+ assert all(v == {"DRY_RUN": "false"} for v in trigger)
617
623
  schedule = [v for k, v in rules.items() if '"schedule"' in k and "score_mrs" in k]
618
624
  assert schedule == [{"DRY_RUN": "false"}]
619
625
  web = [(k, v) for k, v in rules.items() if '"web"' in k and "score_mrs" in k]
@@ -627,7 +633,8 @@ def test_a_mode_pipeline_runs_only_its_own_task():
627
633
  if not isinstance(job, dict) or "rules" not in job:
628
634
  continue
629
635
  for rule in job["rules"]:
630
- if "$mode ==" in rule["if"] and "$mode == null" not in rule["if"]:
636
+ condition = rule.get("if", "")
637
+ if "$mode ==" in condition and "$mode == null" not in condition:
631
638
  continue # the job a mode pipeline is for
632
- if "CI_DEFAULT_BRANCH" in rule["if"]:
633
- assert "$mode == null" in rule["if"], name
639
+ if "CI_DEFAULT_BRANCH" in condition:
640
+ assert "$mode == null" in condition, name
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pdt-cli"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Easily deploy your process to AWS, Azure, Google Cloud, or Windows."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -31,6 +31,7 @@ apps = [
31
31
  "google-cloud-storage",
32
32
  "azure-storage-blob",
33
33
  "azure-identity",
34
+ "azure-keyvault-secrets",
34
35
  ]
35
36
 
36
37
  [project.scripts]
@@ -47,7 +48,7 @@ packages = ["src/pdt"]
47
48
  include = ["src/pdt", "README.md", "tests"]
48
49
 
49
50
  [dependency-groups]
50
- dev = ["pytest", "ruff", "fsspec"]
51
+ dev = ["pytest", "ruff", "fsspec", "boto3"]
51
52
 
52
53
  [tool.pytest.ini_options]
53
54
  testpaths = ["tests", "ci/tests"]
@@ -16,7 +16,7 @@ from pathlib import Path
16
16
 
17
17
  import rich_argparse
18
18
 
19
- from pdt import __version__, completion, config, console, deploy, scaffold
19
+ from pdt import __version__, completion, config, console, deploy, deploy_common, scaffold
20
20
  from pdt.config import ConfigError
21
21
  from pdt.utils.send_email import auth_env_file, email_problems, prepare_email_auth
22
22
 
@@ -46,6 +46,15 @@ def say_no_apps() -> None:
46
46
  console.command("pdt new my-report --from <example>")
47
47
 
48
48
 
49
+ APP_QUESTIONS = {
50
+ "run": "Which app do you want to run?",
51
+ "deploy": "Which app do you want to deploy?",
52
+ "login": "Which app's cloud provider do you want to sign in to?",
53
+ "destroy": "Which app do you want to destroy?",
54
+ "secrets": "Which app's secrets?",
55
+ }
56
+
57
+
49
58
  def choose_app(name: str | None, command: str) -> str | None:
50
59
  """Return the app `pdt <command>` should act on, or None after guiding the user.
51
60
 
@@ -60,7 +69,7 @@ def choose_app(name: str | None, command: str) -> str | None:
60
69
  if name in apps:
61
70
  return name
62
71
  if name is None:
63
- console.heading(f"Which app do you want to {command}? This project has:")
72
+ console.heading(f"{APP_QUESTIONS[command]} This project has:")
64
73
  else:
65
74
  console.error(f"no app named {name!r}. This project has:")
66
75
  shown = apps[:5]
@@ -73,20 +82,25 @@ def choose_app(name: str | None, command: str) -> str | None:
73
82
  return None
74
83
 
75
84
 
76
- def cmd_list(_args) -> int:
77
- apps = config.find_apps()
85
+ def cmd_list(args) -> int:
86
+ if args.names:
87
+ for name in config.find_apps():
88
+ console.say(name)
89
+ return 0
90
+ apps = config.app_folders()
78
91
  if not apps:
79
92
  say_no_apps()
80
93
  return 0
81
94
  rows = []
82
95
  for name in apps:
96
+ enabled = "true" if config.is_enabled(name) else "false"
83
97
  try:
84
98
  app = config.merged_app(name)
85
99
  rows.append([name, app["schedule"] or "-",
86
- app["platform"].get("provider", "-")])
100
+ app["platform"].get("provider", "-"), enabled])
87
101
  except ConfigError as e:
88
- rows.append([name, "-", f"config error: {e}"])
89
- console.table(["App", "Schedule", "Provider"], rows, ["bold cyan"])
102
+ rows.append([name, "-", f"config error: {e}", enabled])
103
+ console.table(["name", "schedule", "provider", "enabled"], rows, ["bold cyan"])
90
104
  return 0
91
105
 
92
106
 
@@ -145,21 +159,28 @@ def cmd_deploy(args) -> int:
145
159
  name = choose_app(args.app, "deploy")
146
160
  if name is None:
147
161
  return 1
148
- return deploy.deploy(name, assume_yes=args.yes, profile=args.profile)
162
+ return deploy.deploy(name, assume_yes=args.yes)
149
163
 
150
164
 
151
165
  def cmd_login(args) -> int:
152
166
  name = choose_app(args.app, "login")
153
167
  if name is None:
154
168
  return 1
155
- return deploy.login(name, profile=args.profile)
169
+ return deploy.login(name)
156
170
 
157
171
 
158
172
  def cmd_destroy(args) -> int:
159
173
  name = choose_app(args.app, "destroy")
160
174
  if name is None:
161
175
  return 1
162
- return deploy.destroy(name, assume_yes=args.yes, profile=args.profile)
176
+ return deploy.destroy(name, assume_yes=args.yes)
177
+
178
+
179
+ def cmd_secrets(args) -> int:
180
+ name = choose_app(args.app, "secrets")
181
+ if name is None:
182
+ return 1
183
+ return deploy.secrets(name, args.action, assume_yes=args.yes, name=args.name)
163
184
 
164
185
 
165
186
  def cmd_storage(args) -> int:
@@ -196,7 +217,10 @@ def build_parser() -> argparse.ArgumentParser:
196
217
  help="which example to copy; run `pdt examples` to see them")
197
218
  source.completer = completion.examples
198
219
  p.set_defaults(func=cmd_new)
199
- add_parser("list", help="show every app").set_defaults(func=cmd_list)
220
+ p = add_parser("list", help="show every app")
221
+ p.add_argument("--names", action="store_true",
222
+ help="print only the name of each enabled app, one per line")
223
+ p.set_defaults(func=cmd_list)
200
224
  add_parser("validate", help="check config and env").set_defaults(func=cmd_validate)
201
225
  p = add_parser("run", help="run an app locally")
202
226
  app = p.add_argument("app", nargs="?",
@@ -208,24 +232,30 @@ def build_parser() -> argparse.ArgumentParser:
208
232
  help="the app's folder name; omit to see the choices")
209
233
  app.completer = completion.apps
210
234
  p.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
211
- profile = p.add_argument("--profile", help="AWS profile name (AWS only)")
212
- profile.completer = completion.profiles
213
235
  p.set_defaults(func=cmd_deploy)
214
236
  p = add_parser("login", help="sign in again to an app's cloud provider")
215
237
  app = p.add_argument("app", nargs="?",
216
238
  help="the app's folder name; omit to see the choices")
217
239
  app.completer = completion.apps
218
- profile = p.add_argument("--profile", help="AWS profile name (AWS only)")
219
- profile.completer = completion.profiles
220
240
  p.set_defaults(func=cmd_login)
221
241
  p = add_parser("destroy", help="tear down an app's deployed resources")
222
242
  app = p.add_argument("app", nargs="?",
223
243
  help="the app's folder name; omit to see the choices")
224
244
  app.completer = completion.apps
225
245
  p.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
226
- profile = p.add_argument("--profile", help="AWS profile name (AWS only)")
227
- profile.completer = completion.profiles
228
246
  p.set_defaults(func=cmd_destroy)
247
+ p = add_parser("secrets", help="compare, send, or fetch a deployed app's .env values")
248
+ app = p.add_argument("app", nargs="?",
249
+ help="the app's folder name; omit to see the choices")
250
+ app.completer = completion.apps
251
+ p.add_argument("action", nargs="?", choices=deploy_common.SECRET_ACTIONS, default="diff",
252
+ help="diff shows what save would change (the default); "
253
+ "save sends your .env values to the deployed app; "
254
+ "get copies the deployed values into a file; "
255
+ "set NAME puts one value, read from stdin, into the deployed app")
256
+ p.add_argument("name", nargs="?", help="the env var that set changes")
257
+ p.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
258
+ p.set_defaults(func=cmd_secrets)
229
259
  p = add_parser("storage", help="read or manage an app's data store")
230
260
  app = p.add_argument("app", help="the app's folder name")
231
261
  app.completer = completion.apps