shipit-cli 0.18.2__tar.gz → 0.19.1__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 (49) hide show
  1. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/PKG-INFO +1 -1
  2. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/pyproject.toml +1 -1
  3. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/assets/php/php.ini +1 -1
  4. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/assets/wordpress/install.sh +9 -2
  5. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/assets/wordpress/wp-config.php +0 -4
  6. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/builders/base.py +1 -0
  7. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/builders/docker.py +13 -0
  8. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/builders/local.py +11 -0
  9. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/cli.py +68 -6
  10. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/providers/mkdocs.py +1 -1
  11. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/providers/node_static.py +1 -1
  12. shipit_cli-0.19.1/src/shipit/providers/staticfile.py +328 -0
  13. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/providers/wordpress.py +3 -2
  14. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/runners/base.py +7 -2
  15. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/runners/local.py +9 -2
  16. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/runners/wasmer.py +47 -23
  17. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/shipit_types.py +16 -1
  18. shipit_cli-0.19.1/src/shipit/version.py +5 -0
  19. shipit_cli-0.19.1/src/shipit/volumes.py +90 -0
  20. shipit_cli-0.19.1/tests/test_cli_after_deploy.py +159 -0
  21. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/tests/test_e2e.py +58 -16
  22. shipit_cli-0.19.1/tests/test_php_provider.py +69 -0
  23. shipit_cli-0.19.1/tests/test_staticfile_provider.py +52 -0
  24. shipit_cli-0.19.1/tests/test_volumes.py +134 -0
  25. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/tests/test_wasmer_annotations.py +25 -0
  26. shipit_cli-0.18.2/src/shipit/providers/staticfile.py +0 -119
  27. shipit_cli-0.18.2/src/shipit/version.py +0 -5
  28. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/.gitignore +0 -0
  29. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/README.md +0 -0
  30. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/__init__.py +0 -0
  31. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/assets/wordpress/.htaccess +0 -0
  32. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/assets/wordpress/start.php +0 -0
  33. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/builders/__init__.py +0 -0
  34. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/generator.py +0 -0
  35. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/procfile.py +0 -0
  36. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/providers/base.py +0 -0
  37. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/providers/go.py +0 -0
  38. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/providers/hugo.py +0 -0
  39. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/providers/jekyll.py +0 -0
  40. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/providers/laravel.py +0 -0
  41. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/providers/php.py +0 -0
  42. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/providers/python.py +0 -0
  43. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/providers/registry.py +0 -0
  44. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/runners/__init__.py +0 -0
  45. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/ui.py +0 -0
  46. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/src/shipit/utils.py +0 -0
  47. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/tests/test_generate_shipit_examples.py +0 -0
  48. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/tests/test_version.py +0 -0
  49. {shipit_cli-0.18.2 → shipit_cli-0.19.1}/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.1
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.1"
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,12 @@ 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
+ shopt -s dotglob nullglob
23
+ cp -R "${WPCONTENT_BASE_PATH}"/* /app/wp-content
24
+ shopt -u dotglob nullglob
25
+ fi
26
+
21
27
  echo "Installing WordPress core"
22
28
 
23
29
  wp core install \
@@ -28,7 +34,6 @@ wp core install \
28
34
  --admin_email="$WP_ADMIN_EMAIL" \
29
35
  --locale="$WP_LOCALE"
30
36
 
31
-
32
37
  if [ "${WP_UPDATE_DB:-false}" = "true" ]; then
33
38
  echo "Updating database..."
34
39
  wp core update-db
@@ -96,11 +101,13 @@ if [ -n "${WP_LOCALE:-}" ]; then
96
101
  wp site switch-language "$WP_LOCALE"
97
102
  fi
98
103
 
99
- cat > /app/wp-content/wp-config.php <<EOF
104
+ if [ ! -f "/app/wp-content/wp-config.php" ]; then
105
+ cat > /app/wp-content/wp-config.php <<EOF
100
106
  <?php
101
107
  // If you need to set custom configuration, you can place it here.
102
108
  // This file will be included by the main wp-config.php after
103
109
  // loading environment variables.
104
110
  EOF
111
+ fi
105
112
 
106
113
  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,15 +31,18 @@ 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
37
38
  from shipit.version import version as shipit_version
39
+ from shipit.volumes import build_volumes, load_volume_mappings
38
40
 
39
41
  app = typer.Typer(invoke_without_command=True)
40
42
 
41
43
  DIR_PATH = Path(__file__).resolve().parent
42
44
  ASSETS_PATH = DIR_PATH / "assets"
45
+ OPTIONAL_RUN_COMMANDS = {"start", "after_deploy"}
43
46
 
44
47
 
45
48
  @dataclass
@@ -195,6 +198,10 @@ class Ctx:
195
198
  step = EnvStep(env_vars)
196
199
  return self.add_step(step)
197
200
 
201
+ def write(self, path: str, content: str) -> Optional[str]:
202
+ step = WriteFileStep(path, content)
203
+ return self.add_step(step)
204
+
198
205
  def add_mount(self, mount: Mount) -> Optional[str]:
199
206
  self.mounts.append(mount)
200
207
  return f"ref:mount:{len(self.mounts) - 1}"
@@ -216,12 +223,18 @@ class Ctx:
216
223
  return f"ref:volume:{len(self.volumes) - 1}"
217
224
 
218
225
  def volume(self, name: str, serve: str) -> Optional[str]:
219
- volume = Volume(name=name, serve_path=Path(serve))
226
+ volume = Volume(
227
+ name=name,
228
+ path=self.build_backend.get_volume_path(name),
229
+ serve_path=Path(serve),
230
+ )
220
231
  ref = self.add_volume(volume)
221
232
  return {
222
233
  "ref": ref,
223
234
  "name": name,
235
+ "path": str(volume.path.absolute()),
224
236
  "serve": str(volume.serve_path),
237
+ "serve_path": str(volume.serve_path),
225
238
  }
226
239
 
227
240
 
@@ -245,6 +258,7 @@ def evaluate_shipit(
245
258
  glb.set("volume", ctx.volume)
246
259
  glb.set("workdir", ctx.workdir)
247
260
  glb.set("copy", ctx.copy)
261
+ glb.set("write", ctx.write)
248
262
  glb.set("path", ctx.path)
249
263
  glb.set("env", ctx.env)
250
264
  glb.set("use", ctx.use)
@@ -359,9 +373,18 @@ def auto(
359
373
  False,
360
374
  help="Run the prepare command after building (defaults to True).",
361
375
  ),
376
+ run_commands: Optional[List[str]] = typer.Option(
377
+ None,
378
+ "--run",
379
+ help="Run one or more serve commands after building. Can be passed multiple times.",
380
+ ),
362
381
  start: bool = typer.Option(
363
382
  False,
364
- help="Run the start command after building.",
383
+ help="Equivalent to `--run=start`.",
384
+ ),
385
+ after_deploy: bool = typer.Option(
386
+ False,
387
+ help="Equivalent to `--run=after_deploy`.",
365
388
  ),
366
389
  regenerate: bool = typer.Option(
367
390
  None,
@@ -479,14 +502,16 @@ def auto(
479
502
  provider=provider,
480
503
  config=config,
481
504
  )
482
- if start or wasmer_deploy or wasmer_deploy_config:
505
+ if run_commands or start or after_deploy or wasmer_deploy or wasmer_deploy_config:
483
506
  serve(
484
507
  path,
485
508
  wasmer=wasmer,
486
509
  wasmer_bin=wasmer_bin,
487
510
  docker=docker,
488
511
  docker_client=docker_client,
512
+ run_commands=run_commands,
489
513
  start=start,
514
+ after_deploy=after_deploy,
490
515
  wasmer_token=wasmer_token,
491
516
  wasmer_registry=wasmer_registry,
492
517
  wasmer_deploy=wasmer_deploy,
@@ -619,9 +644,18 @@ def serve(
619
644
  None,
620
645
  help="Additional options to pass to the Docker client.",
621
646
  ),
647
+ run_commands: Optional[List[str]] = typer.Option(
648
+ None,
649
+ "--run",
650
+ help="Run one or more serve commands. Can be passed multiple times.",
651
+ ),
622
652
  start: Optional[bool] = typer.Option(
623
653
  True,
624
- help="Run the start command after building.",
654
+ help="Equivalent to `--run=start`.",
655
+ ),
656
+ after_deploy: bool = typer.Option(
657
+ False,
658
+ help="Equivalent to `--run=after_deploy`.",
625
659
  ),
626
660
  wasmer_deploy: Optional[bool] = typer.Option(
627
661
  False,
@@ -672,6 +706,12 @@ def serve(
672
706
  else:
673
707
  runner = LocalRunner(build_backend, path)
674
708
 
709
+ commands_to_run = resolve_run_commands(
710
+ run_commands=run_commands,
711
+ start=bool(start),
712
+ after_deploy=after_deploy,
713
+ )
714
+
675
715
  if wasmer_deploy_config:
676
716
  if not isinstance(runner, WasmerRunner):
677
717
  raise RuntimeError("--wasmer-deploy-config requires the Wasmer runner")
@@ -680,8 +720,8 @@ def serve(
680
720
  if not isinstance(runner, WasmerRunner):
681
721
  raise RuntimeError("--wasmer-deploy requires the Wasmer runner")
682
722
  runner.deploy(app_owner=wasmer_app_owner, app_name=wasmer_app_name)
683
- elif start:
684
- runner.run_serve_command("start")
723
+ elif commands_to_run:
724
+ run_serve_commands(path, runner, commands_to_run)
685
725
 
686
726
 
687
727
  @app.command(name="plan")
@@ -1034,6 +1074,7 @@ def build(
1034
1074
 
1035
1075
  # Build and serve
1036
1076
  build_backend.build(serve.name, env, serve.mounts or [], build_steps)
1077
+ build_volumes(path, serve)
1037
1078
  runner.build(serve)
1038
1079
  if serve.prepare and not skip_prepare:
1039
1080
  runner.prepare(env, serve.prepare)
@@ -1053,6 +1094,27 @@ def get_shipit_path(path: Path, shipit_path: Optional[Path] = None) -> Path:
1053
1094
  return shipit_path
1054
1095
 
1055
1096
 
1097
+ def resolve_run_commands(
1098
+ run_commands: Optional[List[str]],
1099
+ start: bool,
1100
+ after_deploy: bool,
1101
+ ) -> List[str]:
1102
+ commands = list(run_commands or [])
1103
+ if after_deploy and "after_deploy" not in commands:
1104
+ commands.append("after_deploy")
1105
+ if start and "start" not in commands:
1106
+ commands.append("start")
1107
+ return commands
1108
+
1109
+
1110
+ def run_serve_commands(path: Path, runner: Runner, commands: List[str]) -> None:
1111
+ volume_mappings = load_volume_mappings(path)
1112
+ for command in commands:
1113
+ if command in OPTIONAL_RUN_COMMANDS and not runner.has_serve_command(command):
1114
+ continue
1115
+ runner.run_serve_command(command, volume_mappings=volume_mappings)
1116
+
1117
+
1056
1118
  def main() -> None:
1057
1119
  args = sys.argv[1:]
1058
1120
  # 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)