make-cloudflare 0.2.0__tar.gz → 0.4.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.2.0
3
+ Version: 0.4.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>
@@ -21,7 +21,7 @@ in the pipeline.
21
21
  # Makefile.py in a consuming repo
22
22
  # /// script
23
23
  # requires-python = ">=3.11"
24
- # dependencies = ["mkrun>=0.4", "make-cloudflare>=0.1"]
24
+ # dependencies = ["mkrun>=0.4.1", "make-cloudflare>=0.3"]
25
25
  # ///
26
26
  from make_cloudflare import cloudflare # importing is what registers the group
27
27
  ```
@@ -29,6 +29,7 @@ from make_cloudflare import cloudflare # importing is what registers the group
29
29
  ```console
30
30
  $ 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
+ $ mk cloudflare.deploy dist --functions functions # with Pages Functions
32
33
  $ mk cloudflare.projects # what the account has
33
34
  $ mk cloudflare.deployments --limit 5 # one project's recent ones
34
35
  $ mk cloudflare.prune --keep 10 # delete all but the newest ten
@@ -50,6 +51,24 @@ Python). Pages never deletes one by itself, and each stays reachable on its own
50
51
  never deleted, however old; a pruned preview that was still its branch's alias
51
52
  takes that branch URL with it.
52
53
 
54
+ **Pages Functions go up with the deployment.** `--functions functions` (or
55
+ `pages.deploy(..., functions=)`) names the tree. It sits **beside** the built
56
+ directory, as wrangler expects, and `/api/...` answers from the same project and
57
+ origin. Bundling it is a build step, not an upload, so that one step is
58
+ `wrangler pages functions build --outfile` (from PATH, or through `npx`). It
59
+ writes exactly the `_worker.bundle` the deployment takes, plus
60
+ `functions-filepath-routing-config.json` and a generated `_routes.json`; the
61
+ site's own `_routes.json` wins. Everything else is still the four calls below.
62
+ Bindings (D1, KV) and secrets are settings on the Pages project, never part of
63
+ the upload.
64
+
65
+ **Those settings are one call.** `pages.configure(project, d1={"DB": "<id>"},
66
+ vars={...}, compatibility_date="2026-10-01")` PATCHes the project's
67
+ `production` (or `preview`) configuration. Only what you pass is sent, and
68
+ Cloudflare merges it in. It takes effect on the **next** deployment. Secrets
69
+ are not set this way: a plain variable is shown in the dashboard, so a secret
70
+ goes through `wrangler pages secret put`.
71
+
53
72
  ## Why this exists
54
73
 
55
74
  `wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
@@ -80,9 +99,9 @@ call returns 200 and the site serves the old bytes, or none:
80
99
 
81
100
  ## What it does not do
82
101
 
83
- **Pages Functions.** A directory holding `_worker.js` or `functions/` is
84
- refused by name rather than half-deployed: bundling a Worker is a build step,
85
- not an upload, and wrangler should keep doing it.
102
+ **Advanced-mode `_worker.js`.** A `_worker.js` (or a `functions/`) *inside*
103
+ the built directory is refused by name: it would otherwise be served as a
104
+ static file. Pages Functions go beside it, through `--functions`.
86
105
 
87
106
  **Steps 2 and 3 are undocumented.** They exist because wrangler uses them, and
88
107
  Cloudflare can change them without a changelog; the documented `deployments`
@@ -8,7 +8,7 @@ in the pipeline.
8
8
  # Makefile.py in a consuming repo
9
9
  # /// script
10
10
  # requires-python = ">=3.11"
11
- # dependencies = ["mkrun>=0.4", "make-cloudflare>=0.1"]
11
+ # dependencies = ["mkrun>=0.4.1", "make-cloudflare>=0.3"]
12
12
  # ///
13
13
  from make_cloudflare import cloudflare # importing is what registers the group
14
14
  ```
@@ -16,6 +16,7 @@ from make_cloudflare import cloudflare # importing is what registers the group
16
16
  ```console
17
17
  $ 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
+ $ mk cloudflare.deploy dist --functions functions # with Pages Functions
19
20
  $ mk cloudflare.projects # what the account has
20
21
  $ mk cloudflare.deployments --limit 5 # one project's recent ones
21
22
  $ mk cloudflare.prune --keep 10 # delete all but the newest ten
@@ -37,6 +38,24 @@ Python). Pages never deletes one by itself, and each stays reachable on its own
37
38
  never deleted, however old; a pruned preview that was still its branch's alias
38
39
  takes that branch URL with it.
39
40
 
41
+ **Pages Functions go up with the deployment.** `--functions functions` (or
42
+ `pages.deploy(..., functions=)`) names the tree. It sits **beside** the built
43
+ directory, as wrangler expects, and `/api/...` answers from the same project and
44
+ origin. Bundling it is a build step, not an upload, so that one step is
45
+ `wrangler pages functions build --outfile` (from PATH, or through `npx`). It
46
+ writes exactly the `_worker.bundle` the deployment takes, plus
47
+ `functions-filepath-routing-config.json` and a generated `_routes.json`; the
48
+ site's own `_routes.json` wins. Everything else is still the four calls below.
49
+ Bindings (D1, KV) and secrets are settings on the Pages project, never part of
50
+ the upload.
51
+
52
+ **Those settings are one call.** `pages.configure(project, d1={"DB": "<id>"},
53
+ vars={...}, compatibility_date="2026-10-01")` PATCHes the project's
54
+ `production` (or `preview`) configuration. Only what you pass is sent, and
55
+ Cloudflare merges it in. It takes effect on the **next** deployment. Secrets
56
+ are not set this way: a plain variable is shown in the dashboard, so a secret
57
+ goes through `wrangler pages secret put`.
58
+
40
59
  ## Why this exists
41
60
 
42
61
  `wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
@@ -67,9 +86,9 @@ call returns 200 and the site serves the old bytes, or none:
67
86
 
68
87
  ## What it does not do
69
88
 
70
- **Pages Functions.** A directory holding `_worker.js` or `functions/` is
71
- refused by name rather than half-deployed: bundling a Worker is a build step,
72
- not an upload, and wrangler should keep doing it.
89
+ **Advanced-mode `_worker.js`.** A `_worker.js` (or a `functions/`) *inside*
90
+ the built directory is refused by name: it would otherwise be served as a
91
+ static file. Pages Functions go beside it, through `--functions`.
73
92
 
74
93
  **Steps 2 and 3 are undocumented.** They exist because wrangler uses them, and
75
94
  Cloudflare can change them without a changelog; the documented `deployments`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "make-cloudflare"
3
- version = "0.2.0"
3
+ version = "0.4.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"
@@ -17,7 +17,7 @@ namespaces, and this package claims only the four names above.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- __version__ = "0.2.0"
20
+ __version__ = "0.4.0"
21
21
 
22
22
  __all__ = ["cloudflare", "pages"]
23
23
 
@@ -24,15 +24,19 @@ def deploy(
24
24
  project: str | None = None,
25
25
  branch: Annotated[str, arg(help="the Pages branch; the production one publishes live")] = "main",
26
26
  keep: Annotated[int, arg(help="deployments to keep afterwards; 0 keeps every one")] = pages.KEEP,
27
+ functions: Annotated[str | None, arg(help="a Pages Functions tree to bundle and deploy with it")] = None,
27
28
  ) -> None:
28
29
  """Publish a built directory to Cloudflare Pages by direct upload.
29
30
 
30
- No Node and no wrangler: four HTTPS calls. `--branch` is what decides
31
+ No Node and no wrangler: four HTTPS calls. (`--functions` is the exception:
32
+ wrangler bundles that tree, and only bundles it.) `--branch` is what decides
31
33
  production -- the project's production branch goes live, every other name
32
34
  lands as a preview on its own URL. Then the project is pruned to its
33
35
  newest `--keep` deployments; the live one always stays.
34
36
  """
35
- url = pages.deploy(Path(directory), project=pages.project_name(project), branch=branch, keep=keep)
37
+ url = pages.deploy(
38
+ Path(directory), project=pages.project_name(project), branch=branch, keep=keep, functions=functions
39
+ )
36
40
  note(f"deployed -- {url}")
37
41
 
38
42
 
@@ -8,6 +8,7 @@ a built directory without installing a JavaScript toolchain to do it.
8
8
 
9
9
  pages.deploy("site/dist", project="mkrun") # production
10
10
  pages.deploy("site/dist", project="mkrun", branch="x") # a preview
11
+ pages.deploy("dist", project="name", functions="functions") # with Pages Functions
11
12
 
12
13
  The flow, and the three things a naive port gets wrong:
13
14
 
@@ -30,6 +31,13 @@ reports success while the site's CSP quietly stops applying.
30
31
  **Steps 2 and 3 are undocumented.** They exist because wrangler uses them;
31
32
  Cloudflare can change them without a changelog. The documented `deployments`
32
33
  endpoint alone cannot upload a file.
34
+
35
+ **Pages Functions are built by wrangler, and only built.** Bundling a
36
+ `functions/` tree into a Worker is esbuild plus Cloudflare's routing glue, and
37
+ `wrangler pages functions build --outfile` writes exactly the `_worker.bundle`
38
+ the deployment takes. That one build step is the only thing wrangler does here;
39
+ the upload is still these calls. Bindings (D1, KV) and secrets are settings on
40
+ the Pages project, never part of the upload.
33
41
  """
34
42
 
35
43
  from __future__ import annotations
@@ -39,17 +47,21 @@ import json
39
47
  import mimetypes
40
48
  import os
41
49
  import secrets as _random
50
+ import shutil
51
+ import tempfile
42
52
  from collections.abc import Iterable, Iterator, Mapping
43
53
  from dataclasses import dataclass
44
54
  from pathlib import Path
45
55
  from typing import Any
46
56
 
47
- from make import env, http, note, step
57
+ from make import env, http, note, sh, step
48
58
  from make.context import mark_sensitive
49
59
  from make.errors import ConfigError, MakeError
50
60
 
51
61
  __all__ = [
52
62
  "Asset",
63
+ "build_functions",
64
+ "configure",
53
65
  "collect",
54
66
  "deploy",
55
67
  "deployments",
@@ -84,9 +96,10 @@ PER_PAGE = 25
84
96
  #: Handled by Pages itself, as fields on the deployment -- never as assets.
85
97
  SPECIAL_FILES = ("_headers", "_redirects", "_routes.json")
86
98
 
87
- #: Never uploaded. `_worker.js` and `functions/` are Pages Functions, which this
88
- #: module does not build: a directory that has them is a Functions project and
89
- #: belongs on wrangler until someone needs it here.
99
+ #: Never uploaded. Pages Functions live *beside* the built directory and are
100
+ #: handed to `deploy(functions=...)`; inside it they would be served as static
101
+ #: files -- the source of the API, readable by anyone. An advanced-mode
102
+ #: `_worker.js` is not built here at all.
90
103
  SKIP_NAMES = {".DS_Store", "Thumbs.db", ".gitkeep", *SPECIAL_FILES}
91
104
  SKIP_DIRS = {".git", "node_modules", "__pycache__", ".wrangler"}
92
105
  FUNCTIONS = ("_worker.js", "_worker.js.map", "_worker.bundle", "functions")
@@ -146,8 +159,9 @@ def collect(directory: str | Path) -> tuple[list[Asset], dict[str, str]]:
146
159
  for name in FUNCTIONS:
147
160
  if (root / name).exists():
148
161
  raise MakeError(
149
- f"{root / name} is a Pages Functions project",
150
- hint="this module uploads static assets only -- deploy it with wrangler",
162
+ f"{root / name} is Pages Functions code inside the built directory",
163
+ hint="keep `functions/` beside the built directory and pass `functions=`; "
164
+ "an advanced-mode `_worker.js` is not supported",
151
165
  )
152
166
 
153
167
  assets: list[Asset] = []
@@ -260,7 +274,9 @@ def _json_call(method: str, url: str, *, auth: str, payload: object, **kwargs: A
260
274
  )
261
275
 
262
276
 
263
- def _multipart(fields: Mapping[str, str], files: Mapping[str, str] | None = None) -> tuple[bytes, str]:
277
+ def _multipart(
278
+ fields: Mapping[str, str], files: Mapping[str, str | bytes] | None = None
279
+ ) -> tuple[bytes, str]:
264
280
  """Encode a deployment's form: plain fields, then the special files as file parts.
265
281
 
266
282
  ⚠ `_headers` and `_redirects` must be FILE parts (a `filename=` on the part),
@@ -279,7 +295,7 @@ def _multipart(fields: Mapping[str, str], files: Mapping[str, str] | None = None
279
295
  chunks.append(f"--{boundary}\r\n".encode())
280
296
  chunks.append(f'Content-Disposition: form-data; name="{name}"; filename="{name}"\r\n'.encode())
281
297
  chunks.append(b"Content-Type: application/octet-stream\r\n\r\n")
282
- chunks.append(value.encode())
298
+ chunks.append(value if isinstance(value, bytes) else value.encode())
283
299
  chunks.append(b"\r\n")
284
300
  chunks.append(f"--{boundary}--\r\n".encode())
285
301
  return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
@@ -299,6 +315,55 @@ def _batches(assets: list[Asset]) -> Iterator[list[Asset]]:
299
315
  yield batch
300
316
 
301
317
 
318
+ # -- Pages Functions -------------------------------------------------------
319
+
320
+
321
+ def _wrangler() -> list[str]:
322
+ found = shutil.which("wrangler")
323
+ return [found] if found else ["npx", "--yes", "wrangler"]
324
+
325
+
326
+ def build_functions(functions: str | Path, directory: str | Path, out: str | Path) -> dict[str, bytes]:
327
+ """Bundle a `functions/` tree; return the deployment's file parts.
328
+
329
+ `_worker.bundle` (the Worker, already in the upload form Pages takes),
330
+ `functions-filepath-routing-config.json`, and `_routes.json` unless the
331
+ built directory brings its own -- the same three `wrangler pages deploy`
332
+ sends. Empty under `--dry-run`, where nothing was built.
333
+ """
334
+ source = Path(functions).resolve()
335
+ root = Path(directory).resolve()
336
+ if not source.is_dir():
337
+ raise MakeError(f"{source} is not a directory", hint="`functions=` names the Pages Functions tree")
338
+ out = Path(out)
339
+ bundle, config, routes = out / "_worker.bundle", out / "routing-config.json", out / "_routes.json"
340
+ sh(
341
+ *_wrangler(),
342
+ "pages",
343
+ "functions",
344
+ "build",
345
+ source,
346
+ "--outfile",
347
+ bundle,
348
+ "--output-config-path",
349
+ config,
350
+ "--output-routes-path",
351
+ routes,
352
+ "--build-output-directory",
353
+ root,
354
+ cwd=source.parent,
355
+ )
356
+ if not bundle.is_file():
357
+ return {}
358
+ parts = {
359
+ "_worker.bundle": bundle.read_bytes(),
360
+ "functions-filepath-routing-config.json": config.read_bytes(),
361
+ }
362
+ if not (root / "_routes.json").is_file() and routes.is_file():
363
+ parts["_routes.json"] = routes.read_bytes()
364
+ return parts
365
+
366
+
302
367
  # -- the tasks' behaviour --------------------------------------------------
303
368
 
304
369
 
@@ -308,6 +373,7 @@ def deploy(
308
373
  project: str,
309
374
  branch: str = "main",
310
375
  keep: int = KEEP,
376
+ functions: str | Path | None = None,
311
377
  account: str | None = None,
312
378
  token: str | None = None,
313
379
  ) -> str:
@@ -318,11 +384,17 @@ def deploy(
318
384
  other name as a preview on its own URL. There is no `--production` flag to
319
385
  forget; there is a branch name to get right.
320
386
 
387
+ `functions` names a Pages Functions tree (`functions/`, beside the built
388
+ directory): it is bundled by `build_functions` and goes up with the
389
+ deployment, so `/api/...` answers from the same project.
390
+
321
391
  Afterwards the project is pruned to its newest `keep` deployments (see
322
392
  `prune`); `keep=0` leaves every one in place.
323
393
  """
324
394
  account, token = _credentials(account, token)
325
395
  assets, specials = collect(directory)
396
+ with tempfile.TemporaryDirectory(prefix="mk-pages-") as scratch:
397
+ worker = build_functions(functions, directory, scratch) if functions is not None else {}
326
398
  plural = "" if len(assets) == 1 else "s"
327
399
  step(f"{len(assets)} file{plural}, {sum(a.size for a in assets) / 1024:.0f} KiB -> {project} ({branch})")
328
400
 
@@ -377,7 +449,7 @@ def deploy(
377
449
  )
378
450
 
379
451
  fields = {"manifest": json.dumps(manifest_of(assets)), "branch": branch}
380
- body, content_type = _multipart(fields, specials)
452
+ body, content_type = _multipart(fields, {**specials, **worker})
381
453
  created = _call(
382
454
  "POST",
383
455
  f"{API}/accounts/{account}/pages/projects/{project}/deployments",
@@ -385,8 +457,8 @@ def deploy(
385
457
  body=body,
386
458
  content_type=content_type,
387
459
  )
388
- if specials:
389
- note("as deployment files, not assets: " + ", ".join(sorted(specials)))
460
+ if specials or worker:
461
+ note("as deployment files, not assets: " + ", ".join(sorted({**specials, **worker})))
390
462
  url = created.get("url") if isinstance(created, dict) else None
391
463
  if keep:
392
464
  prune(project, keep=keep, account=account, token=token)
@@ -423,6 +495,53 @@ def create_project(
423
495
  return created if isinstance(created, dict) else {"name": project}
424
496
 
425
497
 
498
+ def configure(
499
+ project: str,
500
+ *,
501
+ d1: Mapping[str, str] | None = None,
502
+ vars: Mapping[str, str] | None = None,
503
+ compatibility_date: str | None = None,
504
+ compatibility_flags: Iterable[str] | None = None,
505
+ environment: str = "production",
506
+ account: str | None = None,
507
+ token: str | None = None,
508
+ ) -> dict:
509
+ """Set what a project's Functions run with; return the project as Cloudflare now has it.
510
+
511
+ `d1` maps a binding name to a D1 database id (`{"DB": "<uuid>"}`), `vars` sets plain
512
+ environment variables, and the compatibility date and flags pick the runtime.
513
+ `environment` is `production` or `preview`. Only what is passed is sent, and
514
+ Cloudflare merges it into what the project already has -- nothing unnamed is removed.
515
+
516
+ ⚠ Settings take effect on the **next deployment**, never on the live one. Secrets are
517
+ not set here: a value sent this way is stored in plain text and shown in the dashboard,
518
+ so a secret goes through `wrangler pages secret put`.
519
+ """
520
+ if environment not in ("production", "preview"):
521
+ raise MakeError(f"no Pages environment {environment!r}", hint="production or preview")
522
+ settings: dict[str, Any] = {}
523
+ if d1:
524
+ settings["d1_databases"] = {name: {"id": ident} for name, ident in d1.items()}
525
+ if vars:
526
+ settings["env_vars"] = {name: {"type": "plain_text", "value": value} for name, value in vars.items()}
527
+ if compatibility_date:
528
+ settings["compatibility_date"] = compatibility_date
529
+ if compatibility_flags is not None:
530
+ settings["compatibility_flags"] = list(compatibility_flags)
531
+ if not settings:
532
+ raise MakeError(f"nothing to set on {project}", hint="pass d1=, vars= or a compatibility date")
533
+
534
+ account, token = _credentials(account, token)
535
+ step(f"{project} ({environment}): " + ", ".join(sorted(settings)))
536
+ result = _json_call(
537
+ "PATCH",
538
+ f"{API}/accounts/{account}/pages/projects/{project}",
539
+ auth=token,
540
+ payload={"deployment_configs": {environment: settings}},
541
+ )
542
+ return result if isinstance(result, dict) else {"name": project}
543
+
544
+
426
545
  def deployments(project: str, *, account: str | None = None, token: str | None = None) -> list[dict]:
427
546
  """A project's deployments, newest first -- every page of them.
428
547
 
@@ -109,6 +109,13 @@ def test_a_functions_project_is_refused(tmp_path):
109
109
  pages.collect(tmp_path)
110
110
 
111
111
 
112
+ def test_functions_inside_the_built_directory_are_refused(tmp_path):
113
+ """Served from there, the API's source would be a static file anyone can read."""
114
+ build(tmp_path, {"index.html": "x", "functions/api/x.ts": "export const onRequest = 1"})
115
+ with pytest.raises(MakeError, match="inside the built directory"):
116
+ pages.collect(tmp_path)
117
+
118
+
112
119
  def test_an_oversized_asset_is_named(tmp_path):
113
120
  (tmp_path / "big.bin").write_bytes(b"x" * (pages.MAX_ASSET_SIZE + 1))
114
121
  with pytest.raises(MakeError, match=r"big\.bin"):
@@ -360,3 +367,93 @@ def test_deploy_prunes_unless_told_to_keep_everything(monkeypatch, tmp_path):
360
367
  calls.clear()
361
368
  pages.deploy(tmp_path, project="demo", account="acct", token="tok", keep=0)
362
369
  assert deleted(calls) == [] and not any("/deployments?" in url for _, url in calls)
370
+
371
+
372
+ # -- Pages Functions -------------------------------------------------------
373
+
374
+
375
+ def fake_wrangler(monkeypatch):
376
+ """Stands in for `wrangler pages functions build`, writing the three outputs."""
377
+ seen = []
378
+
379
+ def run(*argv, cwd=None, **kwargs):
380
+ argv = [str(a) for a in argv]
381
+ seen.append(argv)
382
+ out = {
383
+ flag: argv[argv.index(flag) + 1]
384
+ for flag in ("--outfile", "--output-config-path", "--output-routes-path")
385
+ }
386
+ pages.Path(out["--outfile"]).write_bytes(b"--bundle--")
387
+ pages.Path(out["--output-config-path"]).write_text('{"routes": []}')
388
+ pages.Path(out["--output-routes-path"]).write_text('{"include": ["/api/*"]}')
389
+
390
+ monkeypatch.setattr(pages, "sh", run)
391
+ return seen
392
+
393
+
394
+ def test_functions_go_up_as_file_parts_of_the_deployment(monkeypatch, tmp_path):
395
+ site = build(tmp_path / "dist", {"index.html": "x"})
396
+ functions = build(tmp_path / "functions", {"api/[[path]].ts": "export const onRequest = 1"})
397
+ seen = fake_wrangler(monkeypatch)
398
+ fake = FakeHTTP()
399
+ deploy(monkeypatch, site, fake, functions=functions)
400
+
401
+ assert seen[0][1:4] == ["pages", "functions", "build"] or seen[0][3:6] == ["pages", "functions", "build"]
402
+ body = fake.body("/deployments").decode()
403
+ assert 'name="_worker.bundle"; filename="_worker.bundle"' in body and "--bundle--" in body
404
+ assert 'name="functions-filepath-routing-config.json"; filename=' in body
405
+ assert 'name="_routes.json"; filename="_routes.json"' in body and "/api/*" in body
406
+ # The functions are never assets.
407
+ assert "[[path]]" not in fake.body("/check-missing").decode()
408
+
409
+
410
+ def test_the_sites_own_routes_beat_the_generated_ones(monkeypatch, tmp_path):
411
+ site = build(tmp_path / "dist", {"index.html": "x", "_routes.json": '{"include": ["/mine/*"]}'})
412
+ functions = build(tmp_path / "functions", {"api/x.ts": "export const onRequest = 1"})
413
+ fake_wrangler(monkeypatch)
414
+ fake = FakeHTTP()
415
+ deploy(monkeypatch, site, fake, functions=functions)
416
+
417
+ body = fake.body("/deployments").decode()
418
+ assert body.count('filename="_routes.json"') == 1
419
+ assert "/mine/*" in body and "/api/*" not in body
420
+
421
+
422
+ def test_a_missing_functions_tree_is_named(monkeypatch, tmp_path):
423
+ site = build(tmp_path / "dist", {"index.html": "x"})
424
+ with pytest.raises(MakeError, match="not a directory"):
425
+ deploy(monkeypatch, site, FakeHTTP(), functions=tmp_path / "nope")
426
+
427
+
428
+ # -- project settings --------------------------------------------------------
429
+
430
+
431
+ def test_configure_patches_only_what_it_is_given(monkeypatch):
432
+ fake = FakeHTTP()
433
+ monkeypatch.setattr(pages.http, "request", fake)
434
+ pages.configure(
435
+ "demo",
436
+ d1={"DB": "uuid-1"},
437
+ vars={"ZONE": "example.dev"},
438
+ compatibility_date="2026-10-01",
439
+ account="acct",
440
+ token="tok",
441
+ )
442
+ method, url, call = fake.calls[0]
443
+ assert (method, url) == ("PATCH", f"{pages.API}/accounts/acct/pages/projects/demo")
444
+ assert json.loads(call["data"]) == {
445
+ "deployment_configs": {
446
+ "production": {
447
+ "d1_databases": {"DB": {"id": "uuid-1"}},
448
+ "env_vars": {"ZONE": {"type": "plain_text", "value": "example.dev"}},
449
+ "compatibility_date": "2026-10-01",
450
+ }
451
+ }
452
+ }
453
+
454
+
455
+ def test_configure_refuses_an_empty_or_unknown_request():
456
+ with pytest.raises(MakeError, match="nothing to set"):
457
+ pages.configure("demo", account="acct", token="tok")
458
+ with pytest.raises(MakeError, match="no Pages environment"):
459
+ pages.configure("demo", vars={"A": "1"}, environment="staging", account="acct", token="tok")