granny-devops 0.17.3__tar.gz → 0.18.0__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 (119) hide show
  1. {granny_devops-0.17.3 → granny_devops-0.18.0}/PKG-INFO +27 -2
  2. {granny_devops-0.17.3 → granny_devops-0.18.0}/README.md +26 -1
  3. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/__init__.py +1 -1
  4. granny_devops-0.18.0/granny/airtable/__init__.py +5 -0
  5. granny_devops-0.18.0/granny/airtable/client.py +113 -0
  6. granny_devops-0.18.0/granny/cli/airtable.py +107 -0
  7. granny_devops-0.18.0/granny/cli/knative.py +378 -0
  8. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/main.py +31 -0
  9. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/serverless.py +46 -0
  10. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/credentials/secrets.py +9 -1
  11. granny_devops-0.18.0/granny/knative/__init__.py +5 -0
  12. granny_devops-0.18.0/granny/knative/client.py +339 -0
  13. {granny_devops-0.17.3 → granny_devops-0.18.0}/pyproject.toml +1 -1
  14. {granny_devops-0.17.3 → granny_devops-0.18.0}/.gitignore +0 -0
  15. {granny_devops-0.17.3 → granny_devops-0.18.0}/LICENSE +0 -0
  16. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/__init__.py +0 -0
  17. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/costs.py +0 -0
  18. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/credits.py +0 -0
  19. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/gpu_pricing.py +0 -0
  20. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/gpus.py +0 -0
  21. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/kubernetes.py +0 -0
  22. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/lambdas.py +0 -0
  23. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/vpcs.py +0 -0
  24. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/authentik/__init__.py +0 -0
  25. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/authentik/client.py +0 -0
  26. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/authentik/provision.py +0 -0
  27. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/aws/__init__.py +0 -0
  28. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/aws/bedrock.py +0 -0
  29. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/aws/quota.py +0 -0
  30. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/__init__.py +0 -0
  31. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/_client.py +0 -0
  32. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/account.py +0 -0
  33. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/deployment.py +0 -0
  34. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/openai.py +0 -0
  35. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/quota.py +0 -0
  36. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/resourcegroup.py +0 -0
  37. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/role.py +0 -0
  38. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/sku.py +0 -0
  39. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/vm.py +0 -0
  40. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/webapp.py +0 -0
  41. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cdn/__init__.py +0 -0
  42. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cdn/bunny.py +0 -0
  43. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/__init__.py +0 -0
  44. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/analyze.py +0 -0
  45. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/authentik.py +0 -0
  46. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/aws.py +0 -0
  47. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/azure.py +0 -0
  48. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/cdn.py +0 -0
  49. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/cloudflare.py +0 -0
  50. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/create.py +0 -0
  51. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/credentials.py +0 -0
  52. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/dns.py +0 -0
  53. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/docker.py +0 -0
  54. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/edge.py +0 -0
  55. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/elk.py +0 -0
  56. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/email.py +0 -0
  57. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/indexing.py +0 -0
  58. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/oauth.py +0 -0
  59. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/storage.py +0 -0
  60. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/zitadel.py +0 -0
  61. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cloudflare/__init__.py +0 -0
  62. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cloudflare/d1.py +0 -0
  63. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cloudflare/r2.py +0 -0
  64. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cloudflare/workers.py +0 -0
  65. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/__init__.py +0 -0
  66. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/auto_certificate.py +0 -0
  67. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/cloudfront-security-headers.js +0 -0
  68. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/letsencrypt_cert.py +0 -0
  69. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/manage-dns.sh +0 -0
  70. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/manage_mailjet_contacts.py +0 -0
  71. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/registrars.py +0 -0
  72. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_aws_cloudfront.py +0 -0
  73. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_bunny_edge_script.py +0 -0
  74. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_bunny_storage.py +0 -0
  75. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  76. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_hetzner_bunny.py +0 -0
  77. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_mailjet_dns.py +0 -0
  78. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_private_cdn.py +0 -0
  79. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_s3_website.py +0 -0
  80. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_scaleway_container.py +0 -0
  81. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_scaleway_faas.py +0 -0
  82. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_workmail.py +0 -0
  83. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/www-redirect-function.js +0 -0
  84. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/credentials/__init__.py +0 -0
  85. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/__init__.py +0 -0
  86. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/base.py +0 -0
  87. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/bunny.py +0 -0
  88. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/cloudflare.py +0 -0
  89. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/cloudns.py +0 -0
  90. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/desec.py +0 -0
  91. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/factory.py +0 -0
  92. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/hetzner.py +0 -0
  93. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/inwx.py +0 -0
  94. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/manual.py +0 -0
  95. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/records.py +0 -0
  96. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/docker/__init__.py +0 -0
  97. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/docker/build_base.py +0 -0
  98. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/edge/__init__.py +0 -0
  99. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/edge/bunny.py +0 -0
  100. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/elk/__init__.py +0 -0
  101. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/elk/client.py +0 -0
  102. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/email/__init__.py +0 -0
  103. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/email/mailjet.py +0 -0
  104. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/email/mailjet_contacts.py +0 -0
  105. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/email/ses_forwarding.py +0 -0
  106. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/email/stalwart.py +0 -0
  107. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/email/workmail.py +0 -0
  108. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/indexing/__init__.py +0 -0
  109. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/indexing/google.py +0 -0
  110. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/indexing/indexnow.py +0 -0
  111. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/report.py +0 -0
  112. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/serverless/__init__.py +0 -0
  113. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/serverless/scaleway.py +0 -0
  114. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/storage/__init__.py +0 -0
  115. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/storage/aws.py +0 -0
  116. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/storage/bunny.py +0 -0
  117. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/storage/hetzner.py +0 -0
  118. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/zitadel/__init__.py +0 -0
  119. {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/zitadel/client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.17.3
3
+ Version: 0.18.0
4
4
  Summary: Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation
5
5
  Author-email: Martin Wieser <martin.wieser@pseekoo.com>
6
6
  License: MIT License
@@ -215,9 +215,11 @@ Common keys:
215
215
  | Provider | Variables |
216
216
  |---|---|
217
217
  | Bunny | `BUNNY_API_KEY` (+ `BUNNY_API_KEY_<CUSTOMER>` for multi-account) |
218
+ | Airtable | `AIRTABLE_API_KEY` |
218
219
  | Cloudflare | `CLOUDFLARE_API_TOKEN` |
219
220
  | Hetzner | `HETZNER_S3_ACCESS_KEY`, `HETZNER_S3_SECRET_KEY`, `HETZNER_DNS_API_TOKEN` |
220
221
  | Scaleway | `SCW_ACCESS_KEY`, `SCW_SECRET_KEY`, `SCW_DEFAULT_PROJECT_ID` |
222
+ | Knative FaaS API | `OBACHAN_API_KEY`, `OBACHAN_API_BASE_URL` |
221
223
  | Mailjet | `MAILJET_API_KEY`, `MAILJET_SECRET_KEY` |
222
224
  | deSEC | `DESEC_API_TOKEN` |
223
225
  | ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
@@ -286,11 +288,30 @@ granny cloudflare d1 create my-app
286
288
  granny cloudflare r2 create my-app-media
287
289
  granny cloudflare site provision my-app --secret-from-vault MAILJET_API_KEY
288
290
 
291
+ # Airtable endpoint maintenance
292
+ granny airtable bases
293
+ granny airtable tables --base app123
294
+ granny airtable find-text --base app123 --table Config --contains old.example.com
295
+ granny airtable replace-text --base app123 --table Config \
296
+ --field Endpoint --old old.example.com --new new.example.com # dry run
297
+ granny airtable replace-text --base app123 --table Config \
298
+ --field Endpoint --old old.example.com --new new.example.com --apply --confirm
299
+
289
300
  # Multi-arch Docker builds with deterministic tags
290
301
  granny docker build-base --image myapp-base --hash-file requirements.txt
291
302
 
292
303
  # Scaleway FaaS
293
304
  granny serverless deploy my-fn --source-dir ./dist --namespace my-app
305
+ granny serverless delete-function my-fn --namespace my-app --yes
306
+
307
+ # Knative FaaS control API (`granny faas` is an alias)
308
+ granny knative list
309
+ granny knative deploy my-fn --image registry.example.com/my-fn:latest \
310
+ --env LOG_LEVEL=info --min-scale 0 --max-scale 5
311
+ granny knative scale my-fn --max-scale 10
312
+ granny knative logs my-fn --tail 100
313
+ granny knative invoke my-fn --method POST --path /process --body '{"job": 42}'
314
+ granny knative remove my-fn --yes
294
315
 
295
316
  # Mailjet + WorkMail
296
317
  granny email mailjet setup-dns example.com
@@ -381,8 +402,9 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
381
402
  | CDN | Bunny |
382
403
  | Edge scripting | Bunny |
383
404
  | Object storage | AWS S3, Bunny Storage, Hetzner S3 |
384
- | Serverless functions | Scaleway FaaS, Scaleway Containers |
405
+ | Serverless functions | Scaleway FaaS, Scaleway Containers, Knative FaaS control API |
385
406
  | Workers / KV / D1 / R2 | Cloudflare |
407
+ | Record search and endpoint maintenance | Airtable |
386
408
  | Email send infra | Mailjet, AWS SES, AWS WorkMail |
387
409
  | AWS inventory | VPCs, Lambdas |
388
410
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
@@ -419,6 +441,7 @@ load_secrets_into_env()
419
441
  ```
420
442
  granny/
421
443
  cli/ Click command groups (granny <group> <verb>)
444
+ airtable/ Airtable record search and endpoint maintenance
422
445
  analyze/ Cross-cloud inventory (AWS, GCP, Azure)
423
446
  authentik/ Authentik admin
424
447
  azure/ Azure account, ARM deployments, Cognitive, App Service, VMs, RBAC roles, quota
@@ -432,12 +455,14 @@ granny/
432
455
  elk/ Elasticsearch / Kibana security user management
433
456
  email/ Mailjet, WorkMail, SES forwarding
434
457
  indexing/ IndexNow + Google Indexing API
458
+ knative/ Knative FaaS control API client
435
459
  serverless/ Scaleway FaaS
436
460
  storage/ Object storage (AWS / Bunny / Hetzner)
437
461
  ```
438
462
 
439
463
  ## Where to look next
440
464
 
465
+ - [`CHANGELOG.md`](CHANGELOG.md) — release history and notable changes.
441
466
  - [`Project_Guidelines.md`](Project_Guidelines.md) — contributor toolchain,
442
467
  conventions, extension checklists, release flow.
443
468
  - [`AGENTS.md`](AGENTS.md) — instructions for AI coding agents working
@@ -81,9 +81,11 @@ Common keys:
81
81
  | Provider | Variables |
82
82
  |---|---|
83
83
  | Bunny | `BUNNY_API_KEY` (+ `BUNNY_API_KEY_<CUSTOMER>` for multi-account) |
84
+ | Airtable | `AIRTABLE_API_KEY` |
84
85
  | Cloudflare | `CLOUDFLARE_API_TOKEN` |
85
86
  | Hetzner | `HETZNER_S3_ACCESS_KEY`, `HETZNER_S3_SECRET_KEY`, `HETZNER_DNS_API_TOKEN` |
86
87
  | Scaleway | `SCW_ACCESS_KEY`, `SCW_SECRET_KEY`, `SCW_DEFAULT_PROJECT_ID` |
88
+ | Knative FaaS API | `OBACHAN_API_KEY`, `OBACHAN_API_BASE_URL` |
87
89
  | Mailjet | `MAILJET_API_KEY`, `MAILJET_SECRET_KEY` |
88
90
  | deSEC | `DESEC_API_TOKEN` |
89
91
  | ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
@@ -152,11 +154,30 @@ granny cloudflare d1 create my-app
152
154
  granny cloudflare r2 create my-app-media
153
155
  granny cloudflare site provision my-app --secret-from-vault MAILJET_API_KEY
154
156
 
157
+ # Airtable endpoint maintenance
158
+ granny airtable bases
159
+ granny airtable tables --base app123
160
+ granny airtable find-text --base app123 --table Config --contains old.example.com
161
+ granny airtable replace-text --base app123 --table Config \
162
+ --field Endpoint --old old.example.com --new new.example.com # dry run
163
+ granny airtable replace-text --base app123 --table Config \
164
+ --field Endpoint --old old.example.com --new new.example.com --apply --confirm
165
+
155
166
  # Multi-arch Docker builds with deterministic tags
156
167
  granny docker build-base --image myapp-base --hash-file requirements.txt
157
168
 
158
169
  # Scaleway FaaS
159
170
  granny serverless deploy my-fn --source-dir ./dist --namespace my-app
171
+ granny serverless delete-function my-fn --namespace my-app --yes
172
+
173
+ # Knative FaaS control API (`granny faas` is an alias)
174
+ granny knative list
175
+ granny knative deploy my-fn --image registry.example.com/my-fn:latest \
176
+ --env LOG_LEVEL=info --min-scale 0 --max-scale 5
177
+ granny knative scale my-fn --max-scale 10
178
+ granny knative logs my-fn --tail 100
179
+ granny knative invoke my-fn --method POST --path /process --body '{"job": 42}'
180
+ granny knative remove my-fn --yes
160
181
 
161
182
  # Mailjet + WorkMail
162
183
  granny email mailjet setup-dns example.com
@@ -247,8 +268,9 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
247
268
  | CDN | Bunny |
248
269
  | Edge scripting | Bunny |
249
270
  | Object storage | AWS S3, Bunny Storage, Hetzner S3 |
250
- | Serverless functions | Scaleway FaaS, Scaleway Containers |
271
+ | Serverless functions | Scaleway FaaS, Scaleway Containers, Knative FaaS control API |
251
272
  | Workers / KV / D1 / R2 | Cloudflare |
273
+ | Record search and endpoint maintenance | Airtable |
252
274
  | Email send infra | Mailjet, AWS SES, AWS WorkMail |
253
275
  | AWS inventory | VPCs, Lambdas |
254
276
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
@@ -285,6 +307,7 @@ load_secrets_into_env()
285
307
  ```
286
308
  granny/
287
309
  cli/ Click command groups (granny <group> <verb>)
310
+ airtable/ Airtable record search and endpoint maintenance
288
311
  analyze/ Cross-cloud inventory (AWS, GCP, Azure)
289
312
  authentik/ Authentik admin
290
313
  azure/ Azure account, ARM deployments, Cognitive, App Service, VMs, RBAC roles, quota
@@ -298,12 +321,14 @@ granny/
298
321
  elk/ Elasticsearch / Kibana security user management
299
322
  email/ Mailjet, WorkMail, SES forwarding
300
323
  indexing/ IndexNow + Google Indexing API
324
+ knative/ Knative FaaS control API client
301
325
  serverless/ Scaleway FaaS
302
326
  storage/ Object storage (AWS / Bunny / Hetzner)
303
327
  ```
304
328
 
305
329
  ## Where to look next
306
330
 
331
+ - [`CHANGELOG.md`](CHANGELOG.md) — release history and notable changes.
307
332
  - [`Project_Guidelines.md`](Project_Guidelines.md) — contributor toolchain,
308
333
  conventions, extension checklists, release flow.
309
334
  - [`AGENTS.md`](AGENTS.md) — instructions for AI coding agents working
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.17.3"
3
+ __version__ = "0.18.0"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -0,0 +1,5 @@
1
+ """Airtable API helpers."""
2
+
3
+ from granny.airtable.client import AirtableClient
4
+
5
+ __all__ = ["AirtableClient"]
@@ -0,0 +1,113 @@
1
+ """Small Airtable Web API client used by the granny CLI."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any, Iterable
6
+ from urllib.parse import quote
7
+
8
+ import requests
9
+
10
+ from granny.credentials import get_secret
11
+
12
+
13
+ @dataclass
14
+ class AirtableClient:
15
+ api_key: str
16
+ api_base: str = "https://api.airtable.com/v0"
17
+ meta_base: str = "https://api.airtable.com/v0/meta"
18
+
19
+ @classmethod
20
+ def from_environment(cls) -> "AirtableClient":
21
+ api_key = get_secret("AIRTABLE_API_KEY")
22
+ if not api_key:
23
+ raise RuntimeError("AIRTABLE_API_KEY is not configured")
24
+ return cls(api_key=api_key)
25
+
26
+ def _request(self, method: str, url: str, **kwargs: Any) -> dict[str, Any]:
27
+ headers = dict(kwargs.pop("headers", {}) or {})
28
+ headers.setdefault("Authorization", f"Bearer {self.api_key}")
29
+ if method.upper() in {"POST", "PATCH", "PUT"}:
30
+ headers.setdefault("Content-Type", "application/json")
31
+ resp = requests.request(method, url, headers=headers, timeout=60, **kwargs)
32
+ if not resp.ok:
33
+ raise RuntimeError(f"Airtable API error {resp.status_code}: {resp.text[:1000]}")
34
+ if not resp.content:
35
+ return {}
36
+ return resp.json()
37
+
38
+ def list_bases(self) -> list[dict[str, Any]]:
39
+ return list(self._request("GET", f"{self.meta_base}/bases").get("bases", []))
40
+
41
+ def list_tables(self, base_id: str) -> list[dict[str, Any]]:
42
+ return list(self._request("GET", f"{self.meta_base}/bases/{base_id}/tables").get("tables", []))
43
+
44
+ def iter_records(
45
+ self,
46
+ base_id: str,
47
+ table: str,
48
+ *,
49
+ fields: Iterable[str] | None = None,
50
+ page_size: int = 100,
51
+ ):
52
+ table_path = quote(table, safe="")
53
+ url = f"{self.api_base}/{base_id}/{table_path}"
54
+ params: list[tuple[str, str | int]] = [("pageSize", page_size)]
55
+ for field in fields or ():
56
+ params.append(("fields[]", field))
57
+ while True:
58
+ data = self._request("GET", url, params=params)
59
+ yield from data.get("records", [])
60
+ offset = data.get("offset")
61
+ if not offset:
62
+ break
63
+ params = [(k, v) for k, v in params if k != "offset"] + [("offset", offset)]
64
+
65
+ def update_record(self, base_id: str, table: str, record_id: str, fields: dict[str, Any]) -> dict[str, Any]:
66
+ table_path = quote(table, safe="")
67
+ return self._request(
68
+ "PATCH",
69
+ f"{self.api_base}/{base_id}/{table_path}/{record_id}",
70
+ json={"fields": fields},
71
+ )
72
+
73
+
74
+ def field_contains(value: Any, needle: str) -> bool:
75
+ return needle in _stringify(value)
76
+
77
+
78
+ def replace_in_value(value: Any, old: str, new: str) -> tuple[Any, bool]:
79
+ """Return value with string occurrences replaced, preserving container shape."""
80
+ if isinstance(value, str):
81
+ replaced = value.replace(old, new)
82
+ return replaced, replaced != value
83
+ if isinstance(value, list):
84
+ changed = False
85
+ out = []
86
+ for item in value:
87
+ replaced, item_changed = replace_in_value(item, old, new)
88
+ out.append(replaced)
89
+ changed = changed or item_changed
90
+ return out, changed
91
+ if isinstance(value, dict):
92
+ changed = False
93
+ out = {}
94
+ for key, item in value.items():
95
+ replaced, item_changed = replace_in_value(item, old, new)
96
+ out[key] = replaced
97
+ changed = changed or item_changed
98
+ return out, changed
99
+ return value, False
100
+
101
+
102
+ def _stringify(value: Any) -> str:
103
+ if value is None:
104
+ return ""
105
+ if isinstance(value, str):
106
+ return value
107
+ if isinstance(value, (int, float, bool)):
108
+ return str(value)
109
+ if isinstance(value, list):
110
+ return "\n".join(_stringify(item) for item in value)
111
+ if isinstance(value, dict):
112
+ return "\n".join(f"{key}: {_stringify(item)}" for key, item in value.items())
113
+ return str(value)
@@ -0,0 +1,107 @@
1
+ """Airtable operational helpers."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ import click
7
+
8
+ from granny.airtable.client import AirtableClient, field_contains, replace_in_value
9
+
10
+
11
+ @click.group()
12
+ def airtable() -> None:
13
+ """Inspect and update Airtable records."""
14
+
15
+
16
+ @airtable.command("bases")
17
+ def list_bases() -> None:
18
+ """List accessible Airtable bases."""
19
+ client = AirtableClient.from_environment()
20
+ for base in client.list_bases():
21
+ click.echo(f"{base.get('id')}\t{base.get('name')}")
22
+
23
+
24
+ @airtable.command("tables")
25
+ @click.option("--base", "base_id", required=True, help="Airtable base id (app...).")
26
+ def list_tables(base_id: str) -> None:
27
+ """List tables in a base."""
28
+ client = AirtableClient.from_environment()
29
+ for table in client.list_tables(base_id):
30
+ click.echo(f"{table.get('id')}\t{table.get('name')}")
31
+
32
+
33
+ @airtable.command("find-text")
34
+ @click.option("--base", "base_id", required=True, help="Airtable base id (app...).")
35
+ @click.option("--table", required=True, help="Table name or table id.")
36
+ @click.option("--contains", "needle", required=True, help="Text/URL fragment to search for.")
37
+ @click.option("--field", "fields", multiple=True, help="Limit search to field(s). Repeatable.")
38
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON lines.")
39
+ def find_text(base_id: str, table: str, needle: str, fields: tuple[str, ...], as_json: bool) -> None:
40
+ """Find records whose field values contain text.
41
+
42
+ This can find old webhook/API endpoints in record fields, button URL fields,
43
+ or config tables. Airtable automations/scripts themselves are not exposed by
44
+ the public Web API; export/edit those manually if they are not represented in
45
+ records.
46
+ """
47
+ client = AirtableClient.from_environment()
48
+ matches = 0
49
+ for record in client.iter_records(base_id, table, fields=fields or None):
50
+ record_fields = record.get("fields", {})
51
+ haystack = fields or tuple(record_fields.keys())
52
+ hit_fields = [name for name in haystack if field_contains(record_fields.get(name), needle)]
53
+ if not hit_fields:
54
+ continue
55
+ matches += 1
56
+ payload = {"id": record.get("id"), "fields": hit_fields}
57
+ if as_json:
58
+ click.echo(json.dumps(payload, ensure_ascii=False))
59
+ else:
60
+ click.echo(f"{record.get('id')}\t{','.join(hit_fields)}")
61
+ if matches == 0:
62
+ click.echo("no matches", err=True)
63
+
64
+
65
+ @airtable.command("replace-text")
66
+ @click.option("--base", "base_id", required=True, help="Airtable base id (app...).")
67
+ @click.option("--table", required=True, help="Table name or table id.")
68
+ @click.option("--old", required=True, help="Old text/URL fragment.")
69
+ @click.option("--new", required=True, help="Replacement text/URL fragment.")
70
+ @click.option("--field", "fields", multiple=True, required=True, help="Field(s) to update. Repeatable.")
71
+ @click.option("--dry-run/--apply", default=True, help="Preview changes unless --apply is used.")
72
+ @click.option("--confirm", is_flag=True, help="Required with --apply.")
73
+ def replace_text(
74
+ base_id: str,
75
+ table: str,
76
+ old: str,
77
+ new: str,
78
+ fields: tuple[str, ...],
79
+ dry_run: bool,
80
+ confirm: bool,
81
+ ) -> None:
82
+ """Replace old endpoint text in selected Airtable record fields."""
83
+ if not dry_run and not confirm:
84
+ raise click.UsageError("--apply requires --confirm")
85
+
86
+ client = AirtableClient.from_environment()
87
+ scanned = changed = updated = 0
88
+ for record in client.iter_records(base_id, table, fields=fields):
89
+ scanned += 1
90
+ record_fields = record.get("fields", {})
91
+ update: dict[str, object] = {}
92
+ for field in fields:
93
+ if field not in record_fields:
94
+ continue
95
+ value, did_change = replace_in_value(record_fields[field], old, new)
96
+ if did_change:
97
+ update[field] = value
98
+ if not update:
99
+ continue
100
+ changed += 1
101
+ click.echo(f"{record.get('id')}\t{','.join(update.keys())}")
102
+ if not dry_run:
103
+ client.update_record(base_id, table, record["id"], update)
104
+ updated += 1
105
+
106
+ mode = "dry-run" if dry_run else "applied"
107
+ click.echo(f"{mode}: scanned={scanned} changed={changed} updated={updated}")