shipit-cli 0.18.2__tar.gz → 0.19.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/PKG-INFO +1 -1
  2. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/pyproject.toml +1 -1
  3. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/assets/php/php.ini +1 -1
  4. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/assets/wordpress/install.sh +7 -2
  5. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/assets/wordpress/wp-config.php +0 -4
  6. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/builders/base.py +1 -0
  7. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/builders/docker.py +13 -0
  8. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/builders/local.py +11 -0
  9. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/cli.py +65 -6
  10. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/providers/mkdocs.py +1 -1
  11. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/providers/node_static.py +1 -1
  12. shipit_cli-0.19.0/src/shipit/providers/staticfile.py +328 -0
  13. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/providers/wordpress.py +3 -2
  14. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/runners/base.py +1 -0
  15. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/runners/local.py +28 -0
  16. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/runners/wasmer.py +57 -22
  17. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/shipit_types.py +16 -1
  18. shipit_cli-0.19.0/src/shipit/version.py +5 -0
  19. shipit_cli-0.19.0/tests/test_cli_after_deploy.py +127 -0
  20. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/tests/test_e2e.py +58 -16
  21. shipit_cli-0.19.0/tests/test_php_provider.py +69 -0
  22. shipit_cli-0.19.0/tests/test_staticfile_provider.py +52 -0
  23. shipit_cli-0.19.0/tests/test_volumes.py +130 -0
  24. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/tests/test_wasmer_annotations.py +25 -0
  25. shipit_cli-0.18.2/src/shipit/providers/staticfile.py +0 -119
  26. shipit_cli-0.18.2/src/shipit/version.py +0 -5
  27. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/.gitignore +0 -0
  28. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/README.md +0 -0
  29. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/__init__.py +0 -0
  30. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/assets/wordpress/.htaccess +0 -0
  31. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/assets/wordpress/start.php +0 -0
  32. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/builders/__init__.py +0 -0
  33. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/generator.py +0 -0
  34. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/procfile.py +0 -0
  35. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/providers/base.py +0 -0
  36. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/providers/go.py +0 -0
  37. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/providers/hugo.py +0 -0
  38. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/providers/jekyll.py +0 -0
  39. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/providers/laravel.py +0 -0
  40. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/providers/php.py +0 -0
  41. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/providers/python.py +0 -0
  42. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/providers/registry.py +0 -0
  43. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/runners/__init__.py +0 -0
  44. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/ui.py +0 -0
  45. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/src/shipit/utils.py +0 -0
  46. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/tests/test_generate_shipit_examples.py +0 -0
  47. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/tests/test_version.py +0 -0
  48. {shipit_cli-0.18.2 → shipit_cli-0.19.0}/tests/test_wordpress_phpix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shipit-cli
3
- Version: 0.18.2
3
+ Version: 0.19.0
4
4
  Summary: Shipit CLI is the best way to build, serve and deploy your projects anywhere.
5
5
  Project-URL: homepage, https://wasmer.io
6
6
  Project-URL: repository, https://github.com/wasmerio/shipit
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shipit-cli"
3
- version = "0.18.2"
3
+ version = "0.19.0"
4
4
  description = "Shipit CLI is the best way to build, serve and deploy your projects anywhere."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -40,7 +40,7 @@ doc_root =
40
40
  user_dir =
41
41
  enable_dl = Off
42
42
  file_uploads = On
43
- upload_max_filesize = 1.5Gb
43
+ upload_max_filesize = 1500M
44
44
  max_input_vars = 6144
45
45
  max_file_uploads = 20
46
46
  allow_url_fopen = On
@@ -18,6 +18,10 @@ echo "Creating required directories..."
18
18
  mkdir -p wp-content/plugins
19
19
  mkdir -p wp-content/upgrade
20
20
 
21
+ if [ -n "${WPCONTENT_BASE_PATH:-}" ] && [ -d "${WPCONTENT_BASE_PATH}" ]; then
22
+ cp -R "${WPCONTENT_BASE_PATH}/." /app/wp-content/
23
+ fi
24
+
21
25
  echo "Installing WordPress core"
22
26
 
23
27
  wp core install \
@@ -28,7 +32,6 @@ wp core install \
28
32
  --admin_email="$WP_ADMIN_EMAIL" \
29
33
  --locale="$WP_LOCALE"
30
34
 
31
-
32
35
  if [ "${WP_UPDATE_DB:-false}" = "true" ]; then
33
36
  echo "Updating database..."
34
37
  wp core update-db
@@ -96,11 +99,13 @@ if [ -n "${WP_LOCALE:-}" ]; then
96
99
  wp site switch-language "$WP_LOCALE"
97
100
  fi
98
101
 
99
- cat > /app/wp-content/wp-config.php <<EOF
102
+ if [ ! -f "/app/wp-content/wp-config.php" ]; then
103
+ cat > /app/wp-content/wp-config.php <<EOF
100
104
  <?php
101
105
  // If you need to set custom configuration, you can place it here.
102
106
  // This file will be included by the main wp-config.php after
103
107
  // loading environment variables.
104
108
  EOF
109
+ fi
105
110
 
106
111
  echo "✅ WordPress Installation complete"
@@ -72,10 +72,6 @@ function get_env_var(string $name, string $default = '')
72
72
  return env_var_deserialize($value);
73
73
  }
74
74
 
75
- if ($default === '') {
76
- error_log("Warning: env var " . $name . " was not provided.");
77
- }
78
-
79
75
  return $default;
80
76
  }
81
77
 
@@ -11,4 +11,5 @@ class BuildBackend(Protocol):
11
11
  ) -> None: ...
12
12
  def get_build_mount_path(self, name: str) -> Path: ...
13
13
  def get_artifact_mount_path(self, name: str) -> Path: ...
14
+ def get_volume_path(self, name: str) -> Path: ...
14
15
  def get_runtime_path(self) -> Optional[str]: ...
@@ -21,6 +21,7 @@ from shipit.shipit_types import (
21
21
  RunStep,
22
22
  Step,
23
23
  UseStep,
24
+ WriteFileStep,
24
25
  WorkdirStep,
25
26
  )
26
27
  from shipit.ui import console, write_stderr, write_stdout
@@ -75,6 +76,9 @@ class DockerBuildBackend:
75
76
  def get_artifact_mount_path(self, name: str) -> Path:
76
77
  return self.docker_out_path / self.get_mount_path(name)
77
78
 
79
+ def get_volume_path(self, name: str) -> Path:
80
+ return self.src_dir / ".shipit" / "volumes" / name
81
+
78
82
  @property
79
83
  def is_depot(self) -> bool:
80
84
  return self.docker_client == "depot"
@@ -210,6 +214,15 @@ RUN curl https://mise.run | sh
210
214
  elif isinstance(step, PathStep):
211
215
  docker_file_contents += f"ENV PATH={step.path}:$PATH\n"
212
216
  env["PATH"] = f"{step.path}{os.pathsep}{env.get('PATH', '')}"
217
+ elif isinstance(step, WriteFileStep):
218
+ content_base64 = base64.b64encode(
219
+ step.content.encode("utf-8")
220
+ ).decode("utf-8")
221
+ target_path = Path(step.path)
222
+ docker_file_contents += (
223
+ f"RUN mkdir -p {target_path.parent} "
224
+ f"&& echo '{content_base64}' | base64 -d > {target_path}\n"
225
+ )
213
226
  elif isinstance(step, UseStep):
214
227
  for dependency in step.dependencies:
215
228
  if dependency.name == "pie":
@@ -17,6 +17,7 @@ from shipit.shipit_types import (
17
17
  RunStep,
18
18
  Step,
19
19
  UseStep,
20
+ WriteFileStep,
20
21
  WorkdirStep,
21
22
  )
22
23
  from shipit.ui import console, write_stderr, write_stdout
@@ -44,6 +45,9 @@ class LocalBuildBackend:
44
45
  def get_artifact_mount_path(self, name: str) -> Path:
45
46
  return self.get_mount_path(name)
46
47
 
48
+ def get_volume_path(self, name: str) -> Path:
49
+ return self.src_dir / ".shipit" / "volumes" / name
50
+
47
51
  def execute_step(self, step: Step, env: Dict[str, str]) -> None:
48
52
  build_path = self.workdir
49
53
  if isinstance(step, UseStep):
@@ -132,6 +136,13 @@ class LocalBuildBackend:
132
136
  console.print(f"[bold]Add {step.path}[/bold] to PATH")
133
137
  fullpath = step.path
134
138
  env["PATH"] = f"{fullpath}{os.pathsep}{env['PATH']}"
139
+ elif isinstance(step, WriteFileStep):
140
+ target = Path(step.path)
141
+ if not target.is_absolute():
142
+ target = build_path / target
143
+ console.print(f"[bold]Write file {target}[/bold]")
144
+ target.parent.mkdir(parents=True, exist_ok=True)
145
+ target.write_text(step.content)
135
146
  else:
136
147
  raise Exception(f"Unknown step type: {type(step)}")
137
148
 
@@ -31,6 +31,7 @@ from shipit.shipit_types import (
31
31
  Step,
32
32
  UseStep,
33
33
  Volume,
34
+ WriteFileStep,
34
35
  WorkdirStep,
35
36
  )
36
37
  from shipit.ui import console
@@ -40,6 +41,7 @@ app = typer.Typer(invoke_without_command=True)
40
41
 
41
42
  DIR_PATH = Path(__file__).resolve().parent
42
43
  ASSETS_PATH = DIR_PATH / "assets"
44
+ OPTIONAL_RUN_COMMANDS = {"start", "after_deploy"}
43
45
 
44
46
 
45
47
  @dataclass
@@ -195,6 +197,10 @@ class Ctx:
195
197
  step = EnvStep(env_vars)
196
198
  return self.add_step(step)
197
199
 
200
+ def write(self, path: str, content: str) -> Optional[str]:
201
+ step = WriteFileStep(path, content)
202
+ return self.add_step(step)
203
+
198
204
  def add_mount(self, mount: Mount) -> Optional[str]:
199
205
  self.mounts.append(mount)
200
206
  return f"ref:mount:{len(self.mounts) - 1}"
@@ -216,12 +222,18 @@ class Ctx:
216
222
  return f"ref:volume:{len(self.volumes) - 1}"
217
223
 
218
224
  def volume(self, name: str, serve: str) -> Optional[str]:
219
- volume = Volume(name=name, serve_path=Path(serve))
225
+ volume = Volume(
226
+ name=name,
227
+ path=self.build_backend.get_volume_path(name),
228
+ serve_path=Path(serve),
229
+ )
220
230
  ref = self.add_volume(volume)
221
231
  return {
222
232
  "ref": ref,
223
233
  "name": name,
234
+ "path": str(volume.path.absolute()),
224
235
  "serve": str(volume.serve_path),
236
+ "serve_path": str(volume.serve_path),
225
237
  }
226
238
 
227
239
 
@@ -245,6 +257,7 @@ def evaluate_shipit(
245
257
  glb.set("volume", ctx.volume)
246
258
  glb.set("workdir", ctx.workdir)
247
259
  glb.set("copy", ctx.copy)
260
+ glb.set("write", ctx.write)
248
261
  glb.set("path", ctx.path)
249
262
  glb.set("env", ctx.env)
250
263
  glb.set("use", ctx.use)
@@ -359,9 +372,18 @@ def auto(
359
372
  False,
360
373
  help="Run the prepare command after building (defaults to True).",
361
374
  ),
375
+ run_commands: Optional[List[str]] = typer.Option(
376
+ None,
377
+ "--run",
378
+ help="Run one or more serve commands after building. Can be passed multiple times.",
379
+ ),
362
380
  start: bool = typer.Option(
363
381
  False,
364
- help="Run the start command after building.",
382
+ help="Equivalent to `--run=start`.",
383
+ ),
384
+ after_deploy: bool = typer.Option(
385
+ False,
386
+ help="Equivalent to `--run=after_deploy`.",
365
387
  ),
366
388
  regenerate: bool = typer.Option(
367
389
  None,
@@ -479,14 +501,16 @@ def auto(
479
501
  provider=provider,
480
502
  config=config,
481
503
  )
482
- if start or wasmer_deploy or wasmer_deploy_config:
504
+ if run_commands or start or after_deploy or wasmer_deploy or wasmer_deploy_config:
483
505
  serve(
484
506
  path,
485
507
  wasmer=wasmer,
486
508
  wasmer_bin=wasmer_bin,
487
509
  docker=docker,
488
510
  docker_client=docker_client,
511
+ run_commands=run_commands,
489
512
  start=start,
513
+ after_deploy=after_deploy,
490
514
  wasmer_token=wasmer_token,
491
515
  wasmer_registry=wasmer_registry,
492
516
  wasmer_deploy=wasmer_deploy,
@@ -619,9 +643,18 @@ def serve(
619
643
  None,
620
644
  help="Additional options to pass to the Docker client.",
621
645
  ),
646
+ run_commands: Optional[List[str]] = typer.Option(
647
+ None,
648
+ "--run",
649
+ help="Run one or more serve commands. Can be passed multiple times.",
650
+ ),
622
651
  start: Optional[bool] = typer.Option(
623
652
  True,
624
- help="Run the start command after building.",
653
+ help="Equivalent to `--run=start`.",
654
+ ),
655
+ after_deploy: bool = typer.Option(
656
+ False,
657
+ help="Equivalent to `--run=after_deploy`.",
625
658
  ),
626
659
  wasmer_deploy: Optional[bool] = typer.Option(
627
660
  False,
@@ -672,6 +705,12 @@ def serve(
672
705
  else:
673
706
  runner = LocalRunner(build_backend, path)
674
707
 
708
+ commands_to_run = resolve_run_commands(
709
+ run_commands=run_commands,
710
+ start=bool(start),
711
+ after_deploy=after_deploy,
712
+ )
713
+
675
714
  if wasmer_deploy_config:
676
715
  if not isinstance(runner, WasmerRunner):
677
716
  raise RuntimeError("--wasmer-deploy-config requires the Wasmer runner")
@@ -680,8 +719,8 @@ def serve(
680
719
  if not isinstance(runner, WasmerRunner):
681
720
  raise RuntimeError("--wasmer-deploy requires the Wasmer runner")
682
721
  runner.deploy(app_owner=wasmer_app_owner, app_name=wasmer_app_name)
683
- elif start:
684
- runner.run_serve_command("start")
722
+ elif commands_to_run:
723
+ run_serve_commands(runner, commands_to_run)
685
724
 
686
725
 
687
726
  @app.command(name="plan")
@@ -1053,6 +1092,26 @@ def get_shipit_path(path: Path, shipit_path: Optional[Path] = None) -> Path:
1053
1092
  return shipit_path
1054
1093
 
1055
1094
 
1095
+ def resolve_run_commands(
1096
+ run_commands: Optional[List[str]],
1097
+ start: bool,
1098
+ after_deploy: bool,
1099
+ ) -> List[str]:
1100
+ commands = list(run_commands or [])
1101
+ if after_deploy and "after_deploy" not in commands:
1102
+ commands.append("after_deploy")
1103
+ if start and "start" not in commands:
1104
+ commands.append("start")
1105
+ return commands
1106
+
1107
+
1108
+ def run_serve_commands(runner: Runner, commands: List[str]) -> None:
1109
+ for command in commands:
1110
+ if command in OPTIONAL_RUN_COMMANDS and not runner.has_serve_command(command):
1111
+ continue
1112
+ runner.run_serve_command(command)
1113
+
1114
+
1056
1115
  def main() -> None:
1057
1116
  args = sys.argv[1:]
1058
1117
  # If no subcommand or first token looks like option/path → default to "build"
@@ -25,7 +25,7 @@ class MkdocsConfig(PythonConfig, StaticFileConfig):
25
25
 
26
26
  class MkdocsProvider(StaticFileProvider):
27
27
  def __init__(self, path: Path, config: MkdocsConfig):
28
- self.path = path
28
+ super().__init__(path, config)
29
29
  self.python_provider = PythonProvider(path, config, only_build=True)
30
30
 
31
31
  @classmethod
@@ -432,7 +432,7 @@ class NodeStaticProvider(StaticFileProvider):
432
432
  'run("cp -R {}/* {}/".format(config.static_dir, static_app.path))'
433
433
  if not self.only_build
434
434
  else None,
435
- ],
435
+ ] + self.build_steps_redirects(),
436
436
  )
437
437
 
438
438
  def prepare_steps(self) -> Optional[list[str]]:
@@ -0,0 +1,328 @@
1
+ import json
2
+ import re
3
+ import shlex
4
+ from dataclasses import dataclass
5
+ from functools import cached_property
6
+ from pathlib import Path
7
+ from typing import Dict, Optional
8
+
9
+ from tomlkit import aot, document, table
10
+ import yaml
11
+ from pydantic_settings import SettingsConfigDict
12
+
13
+ from .base import (
14
+ DetectResult,
15
+ DependencySpec,
16
+ Provider,
17
+ _exists,
18
+ MountSpec,
19
+ ServiceSpec,
20
+ VolumeSpec,
21
+ Config,
22
+ )
23
+
24
+
25
+ class StaticFileConfig(Config):
26
+ model_config = SettingsConfigDict(extra="ignore", env_prefix="SHIPIT_")
27
+
28
+ convert_redirects: bool = True
29
+ sws_version: Optional[str] = "2.38.0"
30
+ static_dir: Optional[str] = None
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class RedirectRule:
35
+ source: str
36
+ destination: str
37
+ kind: int
38
+
39
+
40
+ class StaticFileProvider:
41
+ REDIRECTS_CONFIG_FILE = "sws.toml"
42
+ REDIRECTS_CONFIG_MOUNT = "static_config"
43
+ REDIRECTS_SOURCE = "_redirects"
44
+ REDIRECT_STATUS_CODES = {301, 302}
45
+ _PARAM_PATTERN = re.compile(r":([A-Za-z][A-Za-z0-9_]*)")
46
+ _SOURCE_TOKEN_PATTERN = re.compile(r":([A-Za-z][A-Za-z0-9_]*)|\*")
47
+
48
+ config: Optional[dict] = None
49
+ path: Path
50
+
51
+ def __init__(self, path: Path, config: StaticFileConfig):
52
+ self.path = path
53
+ self.config = config
54
+
55
+ @classmethod
56
+ def load_config(
57
+ cls, path: Path, base_config: Config
58
+ ) -> StaticFileConfig:
59
+ if (path / "Staticfile").exists():
60
+ config = None
61
+ try:
62
+ config = yaml.safe_load((path / "Staticfile").read_text())
63
+ except yaml.YAMLError as e:
64
+ print(f"Error loading Staticfile: {e}")
65
+ pass
66
+
67
+ if config:
68
+ return StaticFileConfig(
69
+ **base_config.model_dump(),
70
+ static_dir=config.get("root"),
71
+ )
72
+ if _exists(path, "public/index.html") or _exists(path, "public/index.htm"):
73
+ return StaticFileConfig(static_dir="public", **base_config.model_dump())
74
+
75
+ return StaticFileConfig(**base_config.model_dump())
76
+
77
+ @classmethod
78
+ def name(cls) -> str:
79
+ return "staticfile"
80
+
81
+ @classmethod
82
+ def detect(
83
+ cls, path: Path, config: Config
84
+ ) -> Optional[DetectResult]:
85
+ is_python_php_js_project = _exists(
86
+ path, "package.json", "pyproject.toml", "composer.json"
87
+ )
88
+ if _exists(path, "Staticfile"):
89
+ return DetectResult(cls.name(), 50)
90
+ if not is_python_php_js_project:
91
+ if _exists(
92
+ path, "index.html", "index.htm", "public/index.htm", "public/index.html"
93
+ ):
94
+ return DetectResult(cls.name(), 10)
95
+ return DetectResult(cls.name(), 10)
96
+ if config.commands.start and config.commands.start.startswith(
97
+ "static-web-server "
98
+ ):
99
+ return DetectResult(cls.name(), 70)
100
+ return None
101
+
102
+ def dependencies(self) -> list[DependencySpec]:
103
+ return [
104
+ DependencySpec(
105
+ "static-web-server",
106
+ var_name="config.sws_version",
107
+ use_in_serve=True,
108
+ )
109
+ ]
110
+
111
+ def build_steps_redirects(self) -> list[str]:
112
+ redirects_config = self.redirects_config
113
+ if not redirects_config:
114
+ return []
115
+ return [
116
+ 'write("{}/%s".format(static_config.path), %s)'
117
+ % (
118
+ self.REDIRECTS_CONFIG_FILE,
119
+ json.dumps(redirects_config),
120
+ )
121
+ ]
122
+
123
+ def build_steps(self) -> list[str]:
124
+ return [
125
+ 'workdir(static_app.path)',
126
+ 'copy({}, ".", ignore=[".git"])'.format(
127
+ json.dumps(self.config.static_dir or ".")
128
+ ),
129
+ ] + self.build_steps_redirects()
130
+
131
+ def prepare_steps(self) -> Optional[list[str]]:
132
+ return None
133
+
134
+ def declarations(self) -> Optional[str]:
135
+ return None
136
+
137
+ def commands(self) -> Dict[str, str]:
138
+ if self.redirects_config:
139
+ return {
140
+ "start": '"static-web-server --root={} --log-level=info --config-file={}/%s --port={}".format(static_app.serve_path, static_config.serve_path, PORT)'
141
+ % self.REDIRECTS_CONFIG_FILE
142
+ }
143
+ return {
144
+ "start": '"static-web-server --root={} --log-level=info --port={}".format(static_app.serve_path, PORT)'
145
+ }
146
+
147
+ def mounts(self) -> list[MountSpec]:
148
+ mounts = [MountSpec("static_app")]
149
+ if self.redirects_config:
150
+ mounts.append(MountSpec(self.REDIRECTS_CONFIG_MOUNT))
151
+ return mounts
152
+
153
+ def volumes(self) -> list[VolumeSpec]:
154
+ return []
155
+
156
+ def env(self) -> Optional[Dict[str, str]]:
157
+ return None
158
+
159
+ def services(self) -> list[ServiceSpec]:
160
+ return []
161
+
162
+ @cached_property
163
+ def redirects_config(self) -> Optional[str]:
164
+ if not self.config.convert_redirects:
165
+ return None
166
+
167
+ redirects_path = self._resolve_redirects_path()
168
+ if not redirects_path.is_file():
169
+ return None
170
+
171
+ rules = self._load_redirect_rules(redirects_path)
172
+ if not rules:
173
+ return None
174
+
175
+ doc = document()
176
+ advanced = table()
177
+ redirects = aot()
178
+ for rule in rules:
179
+ entry = table()
180
+ entry.add("source", rule.source)
181
+ entry.add("destination", rule.destination)
182
+ entry.add("kind", rule.kind)
183
+ redirects.append(entry)
184
+
185
+ advanced.add("redirects", redirects)
186
+ doc.add("advanced", advanced)
187
+ return doc.as_string()
188
+
189
+ def _resolve_redirects_path(self) -> Path:
190
+ if self.config.static_dir:
191
+ static_dir_redirects = (
192
+ self.path / self.config.static_dir / self.REDIRECTS_SOURCE
193
+ )
194
+ if static_dir_redirects.is_file():
195
+ return static_dir_redirects
196
+ return self.path / self.REDIRECTS_SOURCE
197
+
198
+ @classmethod
199
+ def _load_redirect_rules(cls, redirects_path: Path) -> list[RedirectRule]:
200
+ rules: list[RedirectRule] = []
201
+ for line_number, raw_line in enumerate(
202
+ redirects_path.read_text().splitlines(), start=1
203
+ ):
204
+ line = raw_line.strip()
205
+ if not line or line.startswith("#"):
206
+ continue
207
+
208
+ try:
209
+ parts = shlex.split(line)
210
+ except ValueError as exc:
211
+ raise ValueError(
212
+ f"{redirects_path}:{line_number}: invalid _redirects rule"
213
+ ) from exc
214
+
215
+ if len(parts) < 2:
216
+ raise ValueError(
217
+ f"{redirects_path}:{line_number}: expected source and "
218
+ "destination"
219
+ )
220
+
221
+ source, destination, *rest = parts
222
+ kind = 301
223
+ if rest and rest[0].isdigit():
224
+ kind = int(rest[0])
225
+ rest = rest[1:]
226
+
227
+ if kind not in cls.REDIRECT_STATUS_CODES:
228
+ raise ValueError(
229
+ f"{redirects_path}:{line_number}: redirect status {kind} "
230
+ "is not supported by static-web-server"
231
+ )
232
+
233
+ if rest:
234
+ raise ValueError(
235
+ f"{redirects_path}:{line_number}: conditions and forced "
236
+ "redirects are not supported"
237
+ )
238
+
239
+ sws_source, replacements = cls._translate_source(
240
+ redirects_path, line_number, source
241
+ )
242
+ sws_destination = cls._translate_destination(
243
+ redirects_path, line_number, destination, replacements
244
+ )
245
+ rules.append(
246
+ RedirectRule(
247
+ source=sws_source,
248
+ destination=sws_destination,
249
+ kind=kind,
250
+ )
251
+ )
252
+
253
+ return rules
254
+
255
+ @classmethod
256
+ def _translate_source(
257
+ cls, redirects_path: Path, line_number: int, source: str
258
+ ) -> tuple[str, dict[str, int]]:
259
+ if "://" in source:
260
+ raise ValueError(
261
+ f"{redirects_path}:{line_number}: redirect sources must be "
262
+ "local paths"
263
+ )
264
+ if "?" in source:
265
+ raise ValueError(
266
+ f"{redirects_path}:{line_number}: query matching is not "
267
+ "supported"
268
+ )
269
+ if not source.startswith("/"):
270
+ raise ValueError(
271
+ f"{redirects_path}:{line_number}: redirect sources must start "
272
+ "with '/'"
273
+ )
274
+
275
+ translated_parts: list[str] = []
276
+ replacements: dict[str, int] = {}
277
+ last_index = 0
278
+ next_index = 1
279
+
280
+ for match in cls._SOURCE_TOKEN_PATTERN.finditer(source):
281
+ translated_parts.append(source[last_index : match.start()])
282
+ param_name = match.group(1)
283
+ if param_name is not None:
284
+ if param_name in replacements:
285
+ raise ValueError(
286
+ f"{redirects_path}:{line_number}: duplicate source "
287
+ f"parameter :{param_name}"
288
+ )
289
+ replacements[param_name] = next_index
290
+ translated_parts.append("{*}")
291
+ else:
292
+ if "splat" in replacements:
293
+ raise ValueError(
294
+ f"{redirects_path}:{line_number}: only one splat "
295
+ "segment is supported"
296
+ )
297
+ replacements["splat"] = next_index
298
+ translated_parts.append("{**}")
299
+ next_index += 1
300
+ last_index = match.end()
301
+
302
+ translated_parts.append(source[last_index:])
303
+ return "".join(translated_parts), replacements
304
+
305
+ @classmethod
306
+ def _translate_destination(
307
+ cls,
308
+ redirects_path: Path,
309
+ line_number: int,
310
+ destination: str,
311
+ replacements: dict[str, int],
312
+ ) -> str:
313
+ if "*" in destination:
314
+ raise ValueError(
315
+ f"{redirects_path}:{line_number}: destination splats must use "
316
+ ":splat"
317
+ )
318
+
319
+ def replace_param(match: re.Match[str]) -> str:
320
+ param_name = match.group(1)
321
+ if param_name not in replacements:
322
+ raise ValueError(
323
+ f"{redirects_path}:{line_number}: destination references "
324
+ f"unknown parameter :{param_name}"
325
+ )
326
+ return f"${replacements[param_name]}"
327
+
328
+ return cls._PARAM_PATTERN.sub(replace_param, destination)
@@ -80,7 +80,7 @@ class WordPressProvider(PhpProvider):
80
80
  extra_ignore=["wp-content"],
81
81
  after_install=None,
82
82
  after_build=None
83
- )
83
+ ) + ['copy("wp-content", "{}".format(wpcontent_base.path))']
84
84
 
85
85
  def prepare_steps(self) -> Optional[list[str]]:
86
86
  return super().prepare_steps()
@@ -100,7 +100,7 @@ class WordPressProvider(PhpProvider):
100
100
  }
101
101
 
102
102
  def mounts(self) -> list[MountSpec]:
103
- return super().mounts()
103
+ return super().mounts() + [MountSpec("wpcontent_base")]
104
104
 
105
105
  def volumes(self) -> list[VolumeSpec]:
106
106
  return [
@@ -114,6 +114,7 @@ class WordPressProvider(PhpProvider):
114
114
  def env(self) -> Optional[Dict[str, str]]:
115
115
  return {
116
116
  "PAGER": '"cat"',
117
+ "WPCONTENT_BASE_PATH": '"{}".format(wpcontent_base.serve_path)',
117
118
  **(super().env() or {}),
118
119
  }
119
120
 
@@ -14,5 +14,6 @@ class Runner(Protocol):
14
14
  def prepare_build_steps(self, build_steps: List["Step"]) -> List["Step"]: ...
15
15
  def build(self, serve: "Serve") -> None: ...
16
16
  def prepare(self, env: Dict[str, str], prepare: List["PrepareStep"]) -> None: ...
17
+ def has_serve_command(self, command: str) -> bool: ...
17
18
  def run_serve_command(self, command: str) -> None: ...
18
19
  def get_serve_mount_path(self, name: str) -> Path: ...