granny-devops 0.17.3__tar.gz → 0.17.4__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.17.4}/PKG-INFO +14 -1
- {granny_devops-0.17.3 → granny_devops-0.17.4}/README.md +13 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/__init__.py +1 -1
- granny_devops-0.17.4/granny/airtable/__init__.py +5 -0
- granny_devops-0.17.4/granny/airtable/client.py +113 -0
- granny_devops-0.17.4/granny/cli/airtable.py +107 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/main.py +5 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/credentials/secrets.py +1 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/pyproject.toml +1 -1
- {granny_devops-0.17.3 → granny_devops-0.17.4}/.gitignore +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/LICENSE +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/costs.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/credits.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/gpu_pricing.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/kubernetes.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/authentik/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/authentik/client.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/authentik/provision.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/aws/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/aws/bedrock.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/aws/quota.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/_client.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/account.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/deployment.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/openai.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/quota.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/resourcegroup.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/role.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/sku.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/vm.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/webapp.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/analyze.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/authentik.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/aws.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/azure.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/cdn.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/create.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/credentials.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/dns.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/docker.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/edge.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/elk.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/email.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/indexing.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/oauth.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/serverless.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/storage.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/zitadel.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/letsencrypt_cert.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/registrars.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/base.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/bunny.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/desec.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/factory.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/inwx.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/manual.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/records.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/docker/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/docker/build_base.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/edge/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/edge/bunny.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/elk/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/elk/client.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/email/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/email/mailjet.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/email/stalwart.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/email/workmail.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/indexing/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/indexing/google.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/indexing/indexnow.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/report.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/storage/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/storage/aws.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/storage/bunny.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/storage/hetzner.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/zitadel/__init__.py +0 -0
- {granny_devops-0.17.3 → granny_devops-0.17.4}/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
|
+
Version: 0.17.4
|
|
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,6 +215,7 @@ 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` |
|
|
@@ -286,6 +287,15 @@ granny cloudflare d1 create my-app
|
|
|
286
287
|
granny cloudflare r2 create my-app-media
|
|
287
288
|
granny cloudflare site provision my-app --secret-from-vault MAILJET_API_KEY
|
|
288
289
|
|
|
290
|
+
# Airtable endpoint maintenance
|
|
291
|
+
granny airtable bases
|
|
292
|
+
granny airtable tables --base app123
|
|
293
|
+
granny airtable find-text --base app123 --table Config --contains old.example.com
|
|
294
|
+
granny airtable replace-text --base app123 --table Config \
|
|
295
|
+
--field Endpoint --old old.example.com --new new.example.com # dry run
|
|
296
|
+
granny airtable replace-text --base app123 --table Config \
|
|
297
|
+
--field Endpoint --old old.example.com --new new.example.com --apply --confirm
|
|
298
|
+
|
|
289
299
|
# Multi-arch Docker builds with deterministic tags
|
|
290
300
|
granny docker build-base --image myapp-base --hash-file requirements.txt
|
|
291
301
|
|
|
@@ -383,6 +393,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
|
|
|
383
393
|
| Object storage | AWS S3, Bunny Storage, Hetzner S3 |
|
|
384
394
|
| Serverless functions | Scaleway FaaS, Scaleway Containers |
|
|
385
395
|
| Workers / KV / D1 / R2 | Cloudflare |
|
|
396
|
+
| Record search and endpoint maintenance | Airtable |
|
|
386
397
|
| Email send infra | Mailjet, AWS SES, AWS WorkMail |
|
|
387
398
|
| AWS inventory | VPCs, Lambdas |
|
|
388
399
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
@@ -419,6 +430,7 @@ load_secrets_into_env()
|
|
|
419
430
|
```
|
|
420
431
|
granny/
|
|
421
432
|
cli/ Click command groups (granny <group> <verb>)
|
|
433
|
+
airtable/ Airtable record search and endpoint maintenance
|
|
422
434
|
analyze/ Cross-cloud inventory (AWS, GCP, Azure)
|
|
423
435
|
authentik/ Authentik admin
|
|
424
436
|
azure/ Azure account, ARM deployments, Cognitive, App Service, VMs, RBAC roles, quota
|
|
@@ -438,6 +450,7 @@ granny/
|
|
|
438
450
|
|
|
439
451
|
## Where to look next
|
|
440
452
|
|
|
453
|
+
- [`CHANGELOG.md`](CHANGELOG.md) — release history and notable changes.
|
|
441
454
|
- [`Project_Guidelines.md`](Project_Guidelines.md) — contributor toolchain,
|
|
442
455
|
conventions, extension checklists, release flow.
|
|
443
456
|
- [`AGENTS.md`](AGENTS.md) — instructions for AI coding agents working
|
|
@@ -81,6 +81,7 @@ 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` |
|
|
@@ -152,6 +153,15 @@ granny cloudflare d1 create my-app
|
|
|
152
153
|
granny cloudflare r2 create my-app-media
|
|
153
154
|
granny cloudflare site provision my-app --secret-from-vault MAILJET_API_KEY
|
|
154
155
|
|
|
156
|
+
# Airtable endpoint maintenance
|
|
157
|
+
granny airtable bases
|
|
158
|
+
granny airtable tables --base app123
|
|
159
|
+
granny airtable find-text --base app123 --table Config --contains old.example.com
|
|
160
|
+
granny airtable replace-text --base app123 --table Config \
|
|
161
|
+
--field Endpoint --old old.example.com --new new.example.com # dry run
|
|
162
|
+
granny airtable replace-text --base app123 --table Config \
|
|
163
|
+
--field Endpoint --old old.example.com --new new.example.com --apply --confirm
|
|
164
|
+
|
|
155
165
|
# Multi-arch Docker builds with deterministic tags
|
|
156
166
|
granny docker build-base --image myapp-base --hash-file requirements.txt
|
|
157
167
|
|
|
@@ -249,6 +259,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
|
|
|
249
259
|
| Object storage | AWS S3, Bunny Storage, Hetzner S3 |
|
|
250
260
|
| Serverless functions | Scaleway FaaS, Scaleway Containers |
|
|
251
261
|
| Workers / KV / D1 / R2 | Cloudflare |
|
|
262
|
+
| Record search and endpoint maintenance | Airtable |
|
|
252
263
|
| Email send infra | Mailjet, AWS SES, AWS WorkMail |
|
|
253
264
|
| AWS inventory | VPCs, Lambdas |
|
|
254
265
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
@@ -285,6 +296,7 @@ load_secrets_into_env()
|
|
|
285
296
|
```
|
|
286
297
|
granny/
|
|
287
298
|
cli/ Click command groups (granny <group> <verb>)
|
|
299
|
+
airtable/ Airtable record search and endpoint maintenance
|
|
288
300
|
analyze/ Cross-cloud inventory (AWS, GCP, Azure)
|
|
289
301
|
authentik/ Authentik admin
|
|
290
302
|
azure/ Azure account, ARM deployments, Cognitive, App Service, VMs, RBAC roles, quota
|
|
@@ -304,6 +316,7 @@ granny/
|
|
|
304
316
|
|
|
305
317
|
## Where to look next
|
|
306
318
|
|
|
319
|
+
- [`CHANGELOG.md`](CHANGELOG.md) — release history and notable changes.
|
|
307
320
|
- [`Project_Guidelines.md`](Project_Guidelines.md) — contributor toolchain,
|
|
308
321
|
conventions, extension checklists, release flow.
|
|
309
322
|
- [`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}")
|
|
@@ -100,6 +100,11 @@ def _register_commands() -> None:
|
|
|
100
100
|
cli.add_command(authentik)
|
|
101
101
|
except ImportError:
|
|
102
102
|
pass
|
|
103
|
+
try:
|
|
104
|
+
from granny.cli.airtable import airtable
|
|
105
|
+
cli.add_command(airtable)
|
|
106
|
+
except ImportError:
|
|
107
|
+
pass
|
|
103
108
|
try:
|
|
104
109
|
from granny.cli.elk import elk
|
|
105
110
|
cli.add_command(elk)
|
|
@@ -148,6 +148,7 @@ SECRET_MAP: dict[str, str] = {
|
|
|
148
148
|
"HETZNER_S3_SECRET_KEY": "hetzner-s3-secret-key",
|
|
149
149
|
"HETZNER_DNS_API_TOKEN": "hetzner-dns-api-token",
|
|
150
150
|
"BUNNY_API_KEY": "bunny-api-key",
|
|
151
|
+
"AIRTABLE_API_KEY": "airtable-api-key",
|
|
151
152
|
"SCW_ACCESS_KEY": "scw-access-key",
|
|
152
153
|
"SCW_SECRET_KEY": "scw-secret-key",
|
|
153
154
|
"SCW_DEFAULT_PROJECT_ID": "scw-default-project-id",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|