make-cloudflare 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: make-cloudflare
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Cloudflare Pages direct upload for mkrun -- deploy a built site with no Node and no wrangler.
5
5
  Project-URL: Homepage, https://make.optersoft.com
6
6
  Author-email: "Optersoft, S.L." <david@optersoft.com>
@@ -31,6 +31,7 @@ $ mk cloudflare.deploy site/dist --project mkrun # production (branch main)
31
31
  $ mk cloudflare.deploy site/dist --branch try # a preview, on its own URL
32
32
  $ mk cloudflare.projects # what the account has
33
33
  $ mk cloudflare.deployments --limit 5 # one project's recent ones
34
+ $ mk cloudflare.prune --keep 10 # delete all but the newest ten
34
35
  $ mk -n cloudflare.deploy site/dist # the plan; nothing is sent
35
36
  ```
36
37
 
@@ -42,6 +43,13 @@ the environment until a task asks for it, redacted in everything printed, and
42
43
  readable from the encrypted store. The project can come from `--project` or from
43
44
  `CLOUDFLARE_PAGES_PROJECT` in the repo's env layer.
44
45
 
46
+ **Every deploy prunes the project to its newest 10 deployments** (`--keep N`
47
+ to change it, `--keep 0` to keep them all; `pages.deploy(..., keep=)` from
48
+ Python). Pages never deletes one by itself, and each stays reachable on its own
49
+ `<hash>.<project>.pages.dev` URL forever. The live production deployment is
50
+ never deleted, however old; a pruned preview that was still its branch's alias
51
+ takes that branch URL with it.
52
+
45
53
  ## Why this exists
46
54
 
47
55
  `wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
@@ -85,5 +93,5 @@ than a wrapper nobody reads.
85
93
  Nothing here is specific to any owner or account. The group name is
86
94
  `cloudflare`, and it merges with a repo's own `cloudflare.*` tasks — groups are
87
95
  namespaces — so only a same-named task collides; this package claims `deploy`,
88
- `projects` and `deployments`, nothing else. `blake3` is the one dependency, and
96
+ `projects`, `deployments` and `prune`, nothing else. `blake3` is the one dependency, and
89
97
  it lives here rather than in the runner so `mkrun` itself stays dependency-free.
@@ -18,6 +18,7 @@ $ mk cloudflare.deploy site/dist --project mkrun # production (branch main)
18
18
  $ mk cloudflare.deploy site/dist --branch try # a preview, on its own URL
19
19
  $ mk cloudflare.projects # what the account has
20
20
  $ mk cloudflare.deployments --limit 5 # one project's recent ones
21
+ $ mk cloudflare.prune --keep 10 # delete all but the newest ten
21
22
  $ mk -n cloudflare.deploy site/dist # the plan; nothing is sent
22
23
  ```
23
24
 
@@ -29,6 +30,13 @@ the environment until a task asks for it, redacted in everything printed, and
29
30
  readable from the encrypted store. The project can come from `--project` or from
30
31
  `CLOUDFLARE_PAGES_PROJECT` in the repo's env layer.
31
32
 
33
+ **Every deploy prunes the project to its newest 10 deployments** (`--keep N`
34
+ to change it, `--keep 0` to keep them all; `pages.deploy(..., keep=)` from
35
+ Python). Pages never deletes one by itself, and each stays reachable on its own
36
+ `<hash>.<project>.pages.dev` URL forever. The live production deployment is
37
+ never deleted, however old; a pruned preview that was still its branch's alias
38
+ takes that branch URL with it.
39
+
32
40
  ## Why this exists
33
41
 
34
42
  `wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
@@ -72,5 +80,5 @@ than a wrapper nobody reads.
72
80
  Nothing here is specific to any owner or account. The group name is
73
81
  `cloudflare`, and it merges with a repo's own `cloudflare.*` tasks — groups are
74
82
  namespaces — so only a same-named task collides; this package claims `deploy`,
75
- `projects` and `deployments`, nothing else. `blake3` is the one dependency, and
83
+ `projects`, `deployments` and `prune`, nothing else. `blake3` is the one dependency, and
76
84
  it lives here rather than in the runner so `mkrun` itself stays dependency-free.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "make-cloudflare"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Cloudflare Pages direct upload for mkrun -- deploy a built site with no Node and no wrangler."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -2,7 +2,8 @@
2
2
 
3
3
  Today it is Pages direct upload: `cloudflare.deploy` publishes a built
4
4
  directory, `cloudflare.projects` lists what the account has, and
5
- `cloudflare.deployments` shows a project's recent ones. No Node, no wrangler --
5
+ `cloudflare.deployments` shows a project's recent ones, and `cloudflare.prune`
6
+ deletes all but the newest ten (which every deploy also does). No Node, no wrangler --
6
7
  four HTTPS calls and a BLAKE3 hash.
7
8
 
8
9
  Importing is what registers it:
@@ -11,12 +12,12 @@ Importing is what registers it:
11
12
  from make_cloudflare import cloudflare
12
13
 
13
14
  The group merges with a repo's own `cloudflare.*` tasks -- groups are
14
- namespaces, and this package claims only the three names above.
15
+ namespaces, and this package claims only the four names above.
15
16
  """
16
17
 
17
18
  from __future__ import annotations
18
19
 
19
- __version__ = "0.1.0"
20
+ __version__ = "0.2.0"
20
21
 
21
22
  __all__ = ["cloudflare", "pages"]
22
23
 
@@ -23,14 +23,16 @@ def deploy(
23
23
  *,
24
24
  project: str | None = None,
25
25
  branch: Annotated[str, arg(help="the Pages branch; the production one publishes live")] = "main",
26
+ keep: Annotated[int, arg(help="deployments to keep afterwards; 0 keeps every one")] = pages.KEEP,
26
27
  ) -> None:
27
28
  """Publish a built directory to Cloudflare Pages by direct upload.
28
29
 
29
30
  No Node and no wrangler: four HTTPS calls. `--branch` is what decides
30
31
  production -- the project's production branch goes live, every other name
31
- lands as a preview on its own URL.
32
+ lands as a preview on its own URL. Then the project is pruned to its
33
+ newest `--keep` deployments; the live one always stays.
32
34
  """
33
- url = pages.deploy(Path(directory), project=pages.project_name(project), branch=branch)
35
+ url = pages.deploy(Path(directory), project=pages.project_name(project), branch=branch, keep=keep)
34
36
  note(f"deployed -- {url}")
35
37
 
36
38
 
@@ -50,3 +52,11 @@ def deployments(project: str | None = None, *, limit: int = 10) -> None:
50
52
  stage = (entry.get("latest_stage") or {}).get("status", "?")
51
53
  env_name = entry.get("environment", "?")
52
54
  step(f"{entry.get('created_on', '?')} {env_name:<10} {stage:<10} {entry.get('url', '')}")
55
+
56
+
57
+ @task(group="cloudflare", name="prune", dangerous=True, secrets=["CLOUDFLARE_API_TOKEN"])
58
+ def prune(project: str | None = None, *, keep: int = pages.KEEP) -> None:
59
+ """Delete all but a project's newest `--keep` deployments; the live one always stays."""
60
+ name = pages.project_name(project)
61
+ if not pages.prune(name, keep=keep):
62
+ note(f"{name} has {keep} deployments or fewer; nothing to delete")
@@ -57,6 +57,7 @@ __all__ = [
57
57
  "manifest_of",
58
58
  "project_name",
59
59
  "projects",
60
+ "prune",
60
61
  ]
61
62
 
62
63
  API = "https://api.cloudflare.com/client/v4"
@@ -72,6 +73,14 @@ MAX_ASSET_COUNT = 20_000
72
73
  BATCH_FILES = 100
73
74
  BATCH_BYTES = 10 * 1024 * 1024
74
75
 
76
+ #: How many deployments a project keeps after a deploy: the newest ones, plus
77
+ #: the live production deployment wherever it falls. Pages itself keeps every
78
+ #: one forever, each on its own URL.
79
+ KEEP = 10
80
+
81
+ #: One page of the deployments listing.
82
+ PER_PAGE = 25
83
+
75
84
  #: Handled by Pages itself, as fields on the deployment -- never as assets.
76
85
  SPECIAL_FILES = ("_headers", "_redirects", "_routes.json")
77
86
 
@@ -200,7 +209,18 @@ def _credentials(account: str | None, token: str | None) -> tuple[str, str]:
200
209
  return account, token
201
210
 
202
211
 
203
- def _call(
212
+ def _call(method: str, url: str, **kwargs: Any) -> Any:
213
+ """One API call, returning `result` -- a dict or a list, as the endpoint says.
214
+
215
+ Under `--dry-run` nothing is sent and this returns `None`, which every
216
+ caller reads as "unknown". A dry run therefore prints the whole plan
217
+ instead of stopping at the first call whose answer it needed.
218
+ """
219
+ payload = _envelope(method, url, **kwargs)
220
+ return None if payload is None else payload.get("result")
221
+
222
+
223
+ def _envelope(
204
224
  method: str,
205
225
  url: str,
206
226
  *,
@@ -208,13 +228,8 @@ def _call(
208
228
  body: bytes | None = None,
209
229
  content_type: str | None = None,
210
230
  timeout: float = 120.0,
211
- ) -> Any:
212
- """One API call, returning `result` -- a dict or a list, as the endpoint says.
213
-
214
- Under `--dry-run` nothing is sent and this returns `None`, which every
215
- caller reads as "unknown". A dry run therefore prints the whole plan
216
- instead of stopping at the first call whose answer it needed.
217
- """
231
+ ) -> dict | None:
232
+ """One API call, returning Cloudflare's whole answer -- `result_info` too."""
218
233
  headers = {"Authorization": f"Bearer {auth}"}
219
234
  if content_type:
220
235
  headers["Content-Type"] = content_type
@@ -236,7 +251,7 @@ def _call(
236
251
  f"{method} {url} -> {answer.status}" + (f": {detail}" if detail else ""),
237
252
  hint=None if detail else (answer.body[:300] or None),
238
253
  )
239
- return payload.get("result")
254
+ return payload
240
255
 
241
256
 
242
257
  def _json_call(method: str, url: str, *, auth: str, payload: object, **kwargs: Any) -> Any:
@@ -292,6 +307,7 @@ def deploy(
292
307
  *,
293
308
  project: str,
294
309
  branch: str = "main",
310
+ keep: int = KEEP,
295
311
  account: str | None = None,
296
312
  token: str | None = None,
297
313
  ) -> str:
@@ -301,6 +317,9 @@ def deploy(
301
317
  branch (`main` for every project here) as the live deployment and every
302
318
  other name as a preview on its own URL. There is no `--production` flag to
303
319
  forget; there is a branch name to get right.
320
+
321
+ Afterwards the project is pruned to its newest `keep` deployments (see
322
+ `prune`); `keep=0` leaves every one in place.
304
323
  """
305
324
  account, token = _credentials(account, token)
306
325
  assets, specials = collect(directory)
@@ -369,6 +388,8 @@ def deploy(
369
388
  if specials:
370
389
  note("as deployment files, not assets: " + ", ".join(sorted(specials)))
371
390
  url = created.get("url") if isinstance(created, dict) else None
391
+ if keep:
392
+ prune(project, keep=keep, account=account, token=token)
372
393
  return str(url or f"https://{branch}.{project}.pages.dev")
373
394
 
374
395
 
@@ -403,10 +424,55 @@ def create_project(
403
424
 
404
425
 
405
426
  def deployments(project: str, *, account: str | None = None, token: str | None = None) -> list[dict]:
406
- """A project's deployments, newest first."""
427
+ """A project's deployments, newest first -- every page of them.
428
+
429
+ The listing is paginated, and one page is not all of a project that has
430
+ been deployed by hand for a month. `[]` under `--dry-run`.
431
+ """
407
432
  account, token = _credentials(account, token)
408
- result = _call("GET", f"{API}/accounts/{account}/pages/projects/{project}/deployments", auth=token)
409
- return list(result) if isinstance(result, list) else []
433
+ found: list[dict] = []
434
+ page = 1
435
+ while True:
436
+ payload = _envelope(
437
+ "GET",
438
+ f"{API}/accounts/{account}/pages/projects/{project}/deployments?page={page}&per_page={PER_PAGE}",
439
+ auth=token,
440
+ )
441
+ result = (payload or {}).get("result")
442
+ if not isinstance(result, list) or not result:
443
+ return found
444
+ found.extend(result)
445
+ pages_total = ((payload or {}).get("result_info") or {}).get("total_pages")
446
+ if isinstance(pages_total, int) and page >= pages_total:
447
+ return found
448
+ page += 1
449
+
450
+
451
+ def prune(
452
+ project: str, *, keep: int = KEEP, account: str | None = None, token: str | None = None
453
+ ) -> list[dict]:
454
+ """Delete all but the newest `keep` deployments of `project`; return the deleted ones.
455
+
456
+ The live production deployment is never deleted, however old -- Pages
457
+ refuses it anyway, and it is the site. A preview that is still its branch's
458
+ alias goes with `force=true`: its branch URL stops serving, which is what
459
+ pruning a preview means.
460
+ """
461
+ if keep < 1:
462
+ raise MakeError(f"keep={keep} would delete every deployment", hint="keep at least 1")
463
+ account, token = _credentials(account, token)
464
+ base = f"{API}/accounts/{account}/pages/projects/{project}"
465
+ found = deployments(project, account=account, token=token)
466
+ if len(found) <= keep:
467
+ return []
468
+ live = (_call("GET", base, auth=token) or {}).get("canonical_deployment") or {}
469
+ doomed = [d for d in found[keep:] if d.get("id") and d.get("id") != live.get("id")]
470
+ for entry in doomed:
471
+ _call("DELETE", f"{base}/deployments/{entry['id']}?force=true", auth=token)
472
+ if doomed:
473
+ plural = "" if len(doomed) == 1 else "s"
474
+ note(f"pruned {len(doomed)} old deployment{plural} of {project}; kept the newest {keep}")
475
+ return doomed
410
476
 
411
477
 
412
478
  def project_name(project: str | None) -> str:
@@ -133,6 +133,7 @@ def test_deploy_makes_the_four_calls_in_order(monkeypatch, tmp_path):
133
133
  "upload",
134
134
  "upsert-hashes",
135
135
  "deployments",
136
+ "deployments?page=1&per_page=25", # the prune: one deployment, nothing to delete
136
137
  ]
137
138
  assert url == "https://abc123.example.pages.dev"
138
139
 
@@ -282,3 +283,80 @@ def test_a_missing_project_is_created_on_its_production_branch(monkeypatch):
282
283
  method, url, data = calls[-1]
283
284
  assert (method, url.endswith("/accounts/acct/pages/projects")) == ("POST", True)
284
285
  assert json.loads(data) == {"name": "demo", "production_branch": "main"}
286
+
287
+
288
+ # -- pruning ---------------------------------------------------------------
289
+
290
+
291
+ def history_api(count: int, *, live: str, per_page: int = 25):
292
+ """A project with `count` deployments, `d0` newest, served in pages of `per_page`."""
293
+ calls: list[tuple[str, str]] = []
294
+ ids = [f"d{i}" for i in range(count)]
295
+
296
+ def answer(url, *, method="GET", **kwargs):
297
+ calls.append((method, url))
298
+ info = None
299
+ if method == "DELETE":
300
+ result = None
301
+ elif "/deployments?" in url:
302
+ page = int(url.split("page=")[1].split("&")[0])
303
+ chunk = ids[(page - 1) * per_page : page * per_page]
304
+ result = [{"id": i} for i in chunk]
305
+ info = {"page": page, "per_page": per_page, "total_pages": -(-count // per_page)}
306
+ elif url.endswith("/deployments"):
307
+ result = {"url": "https://new.demo.pages.dev"}
308
+ elif url.endswith("/projects/demo"):
309
+ result = {"name": "demo", "canonical_deployment": {"id": live}}
310
+ else:
311
+ result = {"jwt": "jot"} if url.endswith("/upload-token") else []
312
+ body = {"success": True, "errors": [], "result": result}
313
+ if info:
314
+ body["result_info"] = info
315
+ return pages.http.Response(url=url, status=200, body=json.dumps(body))
316
+
317
+ return answer, calls
318
+
319
+
320
+ def deleted(calls) -> list[str]:
321
+ return [url.split("/deployments/")[1].split("?")[0] for method, url in calls if method == "DELETE"]
322
+
323
+
324
+ def test_prune_keeps_the_newest_and_reads_every_page(monkeypatch):
325
+ answer, calls = history_api(60, live="d0")
326
+ monkeypatch.setattr(pages.http, "request", answer)
327
+ gone = pages.prune("demo", keep=10, account="acct", token="tok")
328
+ assert [d["id"] for d in gone] == [f"d{i}" for i in range(10, 60)]
329
+ assert deleted(calls) == [f"d{i}" for i in range(10, 60)]
330
+ assert sum("/deployments?page=" in url for _, url in calls) == 3
331
+ assert all(url.endswith("?force=true") for method, url in calls if method == "DELETE")
332
+
333
+
334
+ def test_prune_never_deletes_the_live_deployment(monkeypatch):
335
+ answer, calls = history_api(15, live="d12") # a run of previews pushed it down
336
+ monkeypatch.setattr(pages.http, "request", answer)
337
+ pages.prune("demo", keep=10, account="acct", token="tok")
338
+ assert deleted(calls) == ["d10", "d11", "d13", "d14"]
339
+
340
+
341
+ def test_prune_with_little_history_deletes_nothing(monkeypatch):
342
+ answer, calls = history_api(10, live="d0")
343
+ monkeypatch.setattr(pages.http, "request", answer)
344
+ assert pages.prune("demo", keep=10, account="acct", token="tok") == []
345
+ assert deleted(calls) == []
346
+
347
+
348
+ def test_prune_refuses_to_keep_nothing():
349
+ with pytest.raises(MakeError, match="every deployment"):
350
+ pages.prune("demo", keep=0, account="acct", token="tok")
351
+
352
+
353
+ def test_deploy_prunes_unless_told_to_keep_everything(monkeypatch, tmp_path):
354
+ build(tmp_path, {"index.html": "x"})
355
+ answer, calls = history_api(12, live="d0")
356
+ monkeypatch.setattr(pages.http, "request", answer)
357
+ pages.deploy(tmp_path, project="demo", account="acct", token="tok")
358
+ assert deleted(calls) == ["d10", "d11"]
359
+
360
+ calls.clear()
361
+ pages.deploy(tmp_path, project="demo", account="acct", token="tok", keep=0)
362
+ assert deleted(calls) == [] and not any("/deployments?" in url for _, url in calls)