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.
- {granny_devops-0.17.3 → granny_devops-0.18.0}/PKG-INFO +27 -2
- {granny_devops-0.17.3 → granny_devops-0.18.0}/README.md +26 -1
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/__init__.py +1 -1
- granny_devops-0.18.0/granny/airtable/__init__.py +5 -0
- granny_devops-0.18.0/granny/airtable/client.py +113 -0
- granny_devops-0.18.0/granny/cli/airtable.py +107 -0
- granny_devops-0.18.0/granny/cli/knative.py +378 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/main.py +31 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/serverless.py +46 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/credentials/secrets.py +9 -1
- granny_devops-0.18.0/granny/knative/__init__.py +5 -0
- granny_devops-0.18.0/granny/knative/client.py +339 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/pyproject.toml +1 -1
- {granny_devops-0.17.3 → granny_devops-0.18.0}/.gitignore +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/LICENSE +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/costs.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/credits.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/gpu_pricing.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/kubernetes.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/authentik/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/authentik/client.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/authentik/provision.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/aws/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/aws/bedrock.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/aws/quota.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/_client.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/account.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/deployment.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/openai.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/quota.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/resourcegroup.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/role.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/sku.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/vm.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/azure/webapp.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/analyze.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/authentik.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/aws.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/azure.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/cdn.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/create.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/credentials.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/dns.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/docker.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/edge.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/elk.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/email.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/indexing.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/oauth.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/storage.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cli/zitadel.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/letsencrypt_cert.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/registrars.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/base.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/bunny.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/desec.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/factory.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/inwx.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/manual.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/dns/records.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/docker/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/docker/build_base.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/edge/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/edge/bunny.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/elk/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/elk/client.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/email/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/email/mailjet.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/email/stalwart.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/email/workmail.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/indexing/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/indexing/google.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/indexing/indexnow.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/report.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/storage/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/storage/aws.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/storage/bunny.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/storage/hetzner.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.18.0}/granny/zitadel/__init__.py +0 -0
- {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.
|
|
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
|
|
@@ -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}")
|