shipit-cli 0.7.0__tar.gz → 0.8.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 (29) hide show
  1. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/PKG-INFO +2 -1
  2. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/pyproject.toml +9 -1
  3. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/assets/wordpress/install.sh +4 -9
  4. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/cli.py +57 -34
  5. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/generator.py +14 -2
  6. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/providers/hugo.py +6 -2
  7. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/providers/laravel.py +1 -1
  8. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/providers/mkdocs.py +2 -0
  9. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/providers/php.py +2 -2
  10. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/providers/python.py +16 -15
  11. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/providers/staticfile.py +12 -2
  12. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/providers/wordpress.py +1 -1
  13. shipit_cli-0.8.0/src/shipit/version.py +5 -0
  14. shipit_cli-0.8.0/tests/test_e2e.py +359 -0
  15. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/tests/test_generate_shipit_examples.py +8 -1
  16. shipit_cli-0.7.0/src/shipit/env.py +0 -30
  17. shipit_cli-0.7.0/src/shipit/version.py +0 -5
  18. shipit_cli-0.7.0/tests/test_examples_build.py +0 -45
  19. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/.gitignore +0 -0
  20. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/README.md +0 -0
  21. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/__init__.py +0 -0
  22. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/assets/php/php.ini +0 -0
  23. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/assets/wordpress/wp-config.php +0 -0
  24. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/procfile.py +0 -0
  25. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/providers/base.py +0 -0
  26. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/providers/gatsby.py +0 -0
  27. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/providers/node_static.py +0 -0
  28. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/src/shipit/providers/registry.py +0 -0
  29. {shipit_cli-0.7.0 → shipit_cli-0.8.0}/tests/test_version.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shipit-cli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Add your description here
5
5
  Project-URL: homepage, https://wasmer.io
6
6
  Project-URL: repository, https://github.com/wasmerio/shipit
7
7
  Project-URL: Changelog, https://github.com/wasmerio/shipit/changelog
8
8
  Requires-Python: >=3.10
9
+ Requires-Dist: dotenv>=0.9.9
9
10
  Requires-Dist: pyyaml>=6.0.2
10
11
  Requires-Dist: requests>=2.32.5
11
12
  Requires-Dist: rich>=14.1.0
@@ -1,10 +1,11 @@
1
1
  [project]
2
2
  name = "shipit-cli"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
+ "dotenv>=0.9.9",
8
9
  "pyyaml>=6.0.2",
9
10
  "requests>=2.32.5",
10
11
  "rich>=14.1.0",
@@ -34,5 +35,12 @@ only-include = ["src/shipit", "tests"]
34
35
 
35
36
  [tool.uv]
36
37
  dev-dependencies = [
38
+ "aiohttp>=3.12.15",
37
39
  "pytest>=8.2",
40
+ "pytest-asyncio>=1.2.0",
41
+ "pytest-rerunfailures>=16.0.1",
42
+ "pytest-xdist>=3.8.0",
38
43
  ]
44
+
45
+ [tool.pytest.ini_options]
46
+ asyncio_mode = "auto"
@@ -12,7 +12,7 @@ echo "" > wp-content/upgrade/.keep
12
12
 
13
13
  echo "Installing WordPress core..."
14
14
 
15
- wp-cli core install \
15
+ wp core install \
16
16
  --url="$WASMER_APP_URL" \
17
17
  --title="$WP_SITE_TITLE" \
18
18
  --admin_user="$WP_ADMIN_USERNAME" \
@@ -21,13 +21,8 @@ wp-cli core install \
21
21
  --locale="$WP_LOCALE"
22
22
 
23
23
 
24
- if [ -z "$WASMER_FIRST_DEPLOYMENT" ]; then
25
- wp-cli core update-db
26
- fi
27
-
28
- echo "Installing theme..."
29
- wp-cli wasmer-aio-install install \
30
- --locale="$WP_LOCALE" \
31
- --theme=twentytwentyfive || true
24
+ if [ -z "$WP_UPDATE_DB" ]; then
25
+ wp core update-db
26
+ fi
32
27
 
33
28
  echo "Installation complete"
@@ -36,6 +36,7 @@ from shipit.version import version as shipit_version
36
36
  from shipit.generator import generate_shipit
37
37
  from shipit.providers.base import CustomCommands
38
38
  from shipit.procfile import Procfile
39
+ from dotenv import dotenv_values
39
40
 
40
41
 
41
42
  console = Console()
@@ -147,10 +148,12 @@ class Build:
147
148
 
148
149
  def write_stdout(line: str) -> None:
149
150
  sys.stdout.write(line) # print to console
151
+ sys.stdout.flush()
150
152
 
151
153
 
152
154
  def write_stderr(line: str) -> None:
153
155
  sys.stderr.write(line) # print to console
156
+ sys.stderr.flush()
154
157
 
155
158
 
156
159
  class MapperItem(TypedDict):
@@ -178,6 +181,16 @@ class Builder(Protocol):
178
181
 
179
182
 
180
183
  class DockerBuilder:
184
+ mise_mapper = {
185
+ "php": {
186
+ "source": "ubi:adwinying/php",
187
+ },
188
+ "composer": {
189
+ "source": "ubi:composer/composer",
190
+ "postinstall": """composer_dir=$(mise where ubi:composer/composer); ln -s "$composer_dir/composer.phar" /usr/local/bin/composer""",
191
+ },
192
+ }
193
+
181
194
  def __init__(self, src_dir: Path, docker_client: Optional[str] = None) -> None:
182
195
  self.src_dir = src_dir
183
196
  self.docker_file_contents = ""
@@ -339,12 +352,17 @@ RUN chmod {oct(mode)[2:]} {path.absolute()}
339
352
  )
340
353
  self.docker_file_contents += f"RUN curl --proto '=https' --tlsv1.2 -sSfL https://get.static-web-server.net | sh\n"
341
354
  return
355
+
356
+ mapped_dependency = self.mise_mapper.get(dependency.name, {})
357
+ package_name = mapped_dependency.get("source", dependency.name)
342
358
  if dependency.version:
343
359
  self.docker_file_contents += (
344
- f"RUN mise use --global {dependency.name}@{dependency.version}\n"
360
+ f"RUN mise use --global {package_name}@{dependency.version}\n"
345
361
  )
346
362
  else:
347
- self.docker_file_contents += f"RUN mise use --global {dependency.name}\n"
363
+ self.docker_file_contents += f"RUN mise use --global {package_name}\n"
364
+ if mapped_dependency.get("postinstall"):
365
+ self.docker_file_contents += f"RUN {mapped_dependency.get('postinstall')}\n"
348
366
 
349
367
  def build(
350
368
  self, env: Dict[str, str], mounts: List[Mount], steps: List[Step]
@@ -357,8 +375,8 @@ RUN chmod {oct(mode)[2:]} {path.absolute()}
357
375
  self.docker_file_contents += """
358
376
  RUN apt-get update \\
359
377
  && apt-get -y --no-install-recommends install \\
360
- build-essential gcc make \\
361
- dpkg-dev pkg-config \\
378
+ build-essential gcc make autoconf libtool bison \\
379
+ dpkg-dev pkg-config re2c locate \\
362
380
  libmariadb-dev libmariadb-dev-compat libpq-dev \\
363
381
  sudo curl ca-certificates \\
364
382
  && rm -rf /var/lib/apt/lists/*
@@ -406,7 +424,7 @@ RUN curl https://mise.run | sh
406
424
  # Read the file content and write it to the target file
407
425
  content_base64 = base64.b64encode(
408
426
  (ASSETS_PATH / step.source).read_bytes()
409
- )
427
+ ).decode("utf-8")
410
428
  self.docker_file_contents += (
411
429
  f"RUN echo '{content_base64}' | base64 -d > {step.target}\n"
412
430
  )
@@ -445,12 +463,6 @@ Shipit
445
463
  def get_path(self) -> Path:
446
464
  return Path("/")
447
465
 
448
- def get_build_path(self) -> Path:
449
- return self.get_path() / "app"
450
-
451
- def get_serve_path(self) -> Path:
452
- return self.get_path() / "serve"
453
-
454
466
  def prepare(self, env: Dict[str, str], prepare: List[PrepareStep]) -> None:
455
467
  raise NotImplementedError
456
468
 
@@ -460,13 +472,12 @@ Shipit
460
472
  for dep in serve.deps:
461
473
  self.add_dependency(dep)
462
474
 
463
- build_path = self.get_build_path()
464
475
  for command in serve.commands:
465
476
  console.print(f"* {command}")
466
477
  command_path = serve_command_path / command
467
478
  self.create_file(
468
479
  command_path,
469
- f"#!/bin/bash\ncd {build_path}\n{serve.commands[command]}",
480
+ f"#!/bin/bash\ncd {serve.cwd}\n{serve.commands[command]}",
470
481
  mode=0o755,
471
482
  )
472
483
 
@@ -479,6 +490,7 @@ class LocalBuilder:
479
490
  def __init__(self, src_dir: Path) -> None:
480
491
  self.src_dir = src_dir
481
492
  self.local_path = self.src_dir / ".shipit" / "local"
493
+ self.serve_bin_path = self.local_path / "serve" / "bin"
482
494
  self.prepare_bash_script = self.local_path / "prepare" / "prepare.sh"
483
495
  self.build_path = self.local_path / "build"
484
496
  self.workdir = self.build_path
@@ -502,6 +514,8 @@ class LocalBuilder:
502
514
  elif isinstance(step, WorkdirStep):
503
515
  console.print(f"[bold]Working in {step.path}[/bold]")
504
516
  self.workdir = step.path
517
+ # We make sure the dir exists
518
+ step.path.mkdir(parents=True, exist_ok=True)
505
519
  elif isinstance(step, RunStep):
506
520
  extra = ""
507
521
  if step.inputs:
@@ -627,12 +641,6 @@ class LocalBuilder:
627
641
  def get_path(self) -> Path:
628
642
  return self.local_path
629
643
 
630
- def get_build_path(self) -> Path:
631
- return self.get_path() / "build"
632
-
633
- def get_serve_path(self) -> Path:
634
- return self.get_path() / "serve"
635
-
636
644
  def build_prepare(self, serve: Serve) -> None:
637
645
  self.prepare_bash_script.parent.mkdir(parents=True, exist_ok=True)
638
646
  commands: List[str] = []
@@ -675,14 +683,13 @@ class LocalBuilder:
675
683
  def build_serve(self, serve: Serve) -> None:
676
684
  # Remember serve configuration for run-time
677
685
  console.print("\n[bold]Building serve[/bold]")
678
- serve_command_path = self.get_serve_path() / "bin"
679
- serve_command_path.mkdir(parents=True, exist_ok=False)
686
+ self.serve_bin_path.mkdir(parents=True, exist_ok=False)
680
687
  path = self.get_path() / ".path"
681
688
  path_text = path.read_text()
682
689
  console.print(f"[bold]Serve Commands:[/bold]")
683
690
  for command in serve.commands:
684
691
  console.print(f"* {command}")
685
- command_path = serve_command_path / command
692
+ command_path = self.serve_bin_path / command
686
693
  env_vars = ""
687
694
  if serve.env:
688
695
  env_vars = " ".join([f"{k}={v}" for k, v in serve.env.items()])
@@ -706,8 +713,7 @@ class LocalBuilder:
706
713
 
707
714
  def run_serve_command(self, command: str) -> None:
708
715
  console.print(f"\n[bold]Running {command} command[/bold]")
709
- base_path = self.get_serve_path() / "bin"
710
- command_path = base_path / command
716
+ command_path = self.serve_bin_path / command
711
717
  sh.Command(str(command_path))(_out=write_stdout, _err=write_stderr)
712
718
 
713
719
 
@@ -807,9 +813,6 @@ class WasmerBuilder:
807
813
  ) -> None:
808
814
  return self.inner_builder.build(env, mounts, build)
809
815
 
810
- def get_build_path(self) -> Path:
811
- return Path("/app")
812
-
813
816
  def build_prepare(self, serve: Serve) -> None:
814
817
  print("Building prepare")
815
818
  prepare_dir = self.wasmer_dir_path / "prepare"
@@ -877,10 +880,10 @@ class WasmerBuilder:
877
880
  )
878
881
 
879
882
  def build_serve(self, serve: Serve) -> None:
880
- from tomlkit import comment, document, nl, table, aot, string
883
+ from tomlkit import comment, document, nl, table, aot, string, array
881
884
 
882
885
  doc = document()
883
- doc.add(comment(f"File generated by Shipit {shipit_version}"))
886
+ doc.add(comment(f"Wasmer manifest generated with Shipit v{shipit_version}"))
884
887
  package = table()
885
888
  doc.add("package", package)
886
889
  package.add("entrypoint", "start")
@@ -953,14 +956,14 @@ class WasmerBuilder:
953
956
  wasi_args = table()
954
957
  if serve.cwd:
955
958
  wasi_args.add("cwd", serve.cwd)
956
- wasi_args.add("main-args", parts[1:])
959
+ wasi_args.add("main-args", array(parts[1:]).multiline(True))
957
960
  env = program_binary.get("env") or {}
958
961
  if serve.env:
959
962
  env.update(serve.env)
960
963
  if env:
964
+ arr = array([f"{k}={v}" for k, v in env.items()]).multiline(True)
961
965
  wasi_args.add(
962
- "env",
963
- [f"{k}={v}" for k, v in env.items()],
966
+ "env", arr
964
967
  )
965
968
  title = string("annotations.wasi", literal=False)
966
969
  command.add(title, wasi_args)
@@ -1038,7 +1041,7 @@ class WasmerBuilder:
1038
1041
  )
1039
1042
  yaml_config["jobs"] = jobs
1040
1043
 
1041
- app_yaml = yaml.dump(yaml_config)
1044
+ app_yaml = yaml.dump(yaml_config,)
1042
1045
 
1043
1046
  console.print(f"\n[bold]Created app.yaml manifest ✅[/bold]")
1044
1047
  app_yaml_panel = Panel(
@@ -1385,6 +1388,10 @@ def auto(
1385
1388
  None,
1386
1389
  help="The start command to use (overwrites the default)",
1387
1390
  ),
1391
+ env_name: Optional[str] = typer.Option(
1392
+ None,
1393
+ help="The environment to use (defaults to `.env`, it will use .env.<env_name> if provided)",
1394
+ ),
1388
1395
  ):
1389
1396
  if not path.exists():
1390
1397
  raise Exception(f"The path {path} does not exist")
@@ -1408,6 +1415,7 @@ def auto(
1408
1415
  wasmer_token=wasmer_token,
1409
1416
  wasmer_bin=wasmer_bin,
1410
1417
  skip_prepare=skip_prepare,
1418
+ env_name=env_name,
1411
1419
  )
1412
1420
  if start or wasmer_deploy:
1413
1421
  serve(
@@ -1612,6 +1620,10 @@ def build(
1612
1620
  None,
1613
1621
  help="Use a specific Docker client (such as depot, podman, etc.)",
1614
1622
  ),
1623
+ env_name: Optional[str] = typer.Option(
1624
+ None,
1625
+ help="The environment to use (defaults to `.env`, it will use .env.<env_name> if provided)",
1626
+ ),
1615
1627
  ) -> None:
1616
1628
  if not path.exists():
1617
1629
  raise Exception(f"The path {path} does not exist")
@@ -1664,6 +1676,14 @@ def build(
1664
1676
  "CLICOLOR": os.environ.get("CLICOLOR", "0"),
1665
1677
  }
1666
1678
  serve = next(iter(ctx.serves.values()))
1679
+ serve.env = serve.env or {}
1680
+ if (path / ".env").exists():
1681
+ env_vars = dotenv_values(path / ".env")
1682
+ serve.env.update(env_vars)
1683
+
1684
+ if (path / f".env.{env_name}").exists():
1685
+ env_vars = dotenv_values(path / f".env.{env_name}")
1686
+ serve.env.update(env_vars)
1667
1687
 
1668
1688
  # Build and serve
1669
1689
  builder.build(env, serve.mounts, serve.build)
@@ -1686,8 +1706,11 @@ def main() -> None:
1686
1706
  app()
1687
1707
  except Exception as e:
1688
1708
  console.print(f"[bold red]{type(e).__name__}[/bold red]: {e}")
1689
- raise e
1709
+ # raise e
1690
1710
 
1691
1711
 
1692
1712
  if __name__ == "__main__":
1693
1713
  main()
1714
+
1715
+ def flatten(xss):
1716
+ return [x for xs in xss for x in xs]
@@ -109,7 +109,7 @@ def generate_shipit(path: Path, custom_commands: CustomCommands) -> str:
109
109
 
110
110
  build_steps_block = ",\n".join([f" {s}" for s in build_steps])
111
111
  deps_array = ", ".join(serve_dep_vars)
112
- commands_lines = ",\n".join([f' "{k}": {v}' for k, v in plan.commands.items()])
112
+ commands_lines = ",\n".join([f' "{k}": {v}.replace("$PORT", PORT)' for k, v in plan.commands.items()])
113
113
  env_lines = None
114
114
  if plan.env is not None:
115
115
  if len(plan.env) == 0:
@@ -119,31 +119,43 @@ def generate_shipit(path: Path, custom_commands: CustomCommands) -> str:
119
119
  mounts_block = None
120
120
  volumes_block = None
121
121
  attach_serve_names: list[str] = []
122
+
122
123
  if plan.mounts:
123
124
  mounts = list(filter(lambda m: m.attach_to_serve, plan.mounts))
124
125
  attach_serve_names = [m.name for m in mounts]
125
126
  mounts_block = ",\n".join([f" {m.name}" for m in mounts])
127
+
126
128
  if plan.volumes:
127
129
  volumes_block = ",\n".join(
128
130
  [f" {v.var_name or v.name}" for v in plan.volumes]
129
131
  )
130
132
 
131
133
  out: List[str] = []
134
+
132
135
  if dep_block:
133
136
  out.append(dep_block)
134
137
  out.append("")
138
+
135
139
  for m in plan.mounts:
136
140
  out.append(f"{m.name} = mount(\"{m.name}\")")
141
+ out.append("")
142
+
137
143
  if plan.volumes:
138
144
  for v in plan.volumes:
139
145
  out.append(f"{v.var_name or v.name} = volume(\"{v.name}\", {v.serve_path})")
146
+ out.append("")
147
+
140
148
  if plan.services:
141
149
  for s in plan.services:
142
150
  out.append(f"{s.name} = service(\n name=\"{s.name}\",\n provider=\"{s.provider}\"\n)")
151
+ out.append("")
152
+
153
+ out.append("PORT = getenv(\"PORT\") or\"8080\"")
143
154
 
144
155
  if plan.declarations:
145
156
  out.append(plan.declarations)
146
- out.append("")
157
+
158
+ out.append("")
147
159
  out.append("serve(")
148
160
  out.append(f' name="{plan.serve_name}",')
149
161
  out.append(f' provider="{plan.provider}",')
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import Dict, Optional
5
5
 
6
- from .base import DetectResult, DependencySpec, Provider, _exists, ServiceSpec, VolumeSpec, CustomCommands
6
+ from .base import DetectResult, DependencySpec, Provider, _exists, ServiceSpec, VolumeSpec, CustomCommands, MountSpec
7
7
  from .staticfile import StaticFileProvider
8
8
 
9
9
  class HugoProvider(StaticFileProvider):
@@ -43,10 +43,14 @@ class HugoProvider(StaticFileProvider):
43
43
 
44
44
  def build_steps(self) -> list[str]:
45
45
  return [
46
+ 'workdir(temp["build"])',
46
47
  'copy(".", ".", ignore=[".git"])',
47
48
  'run("hugo build --destination={}".format(app["build"]), group="build")',
48
49
  ]
49
-
50
+
51
+ def mounts(self) -> list[MountSpec]:
52
+ return [MountSpec("temp", attach_to_serve=False), *super().mounts()]
53
+
50
54
  def services(self) -> list[ServiceSpec]:
51
55
  return []
52
56
 
@@ -80,7 +80,7 @@ class LaravelProvider:
80
80
 
81
81
  def commands(self) -> Dict[str, str]:
82
82
  return {
83
- "start": '"php -S localhost:8080 -t public"',
83
+ "start": 'f"php -S localhost:{PORT} -t public"',
84
84
  "after_deploy": '"php artisan migrate"',
85
85
  }
86
86
 
@@ -30,6 +30,8 @@ class MkdocsProvider(StaticFileProvider):
30
30
  def detect(cls, path: Path, custom_commands: CustomCommands) -> Optional[DetectResult]:
31
31
  if _exists(path, "mkdocs.yml", "mkdocs.yaml"):
32
32
  return DetectResult(cls.name(), 85)
33
+ if custom_commands.build and custom_commands.build.startswith("mkdocs "):
34
+ return DetectResult(cls.name(), 85)
33
35
  return None
34
36
 
35
37
  def initialize(self) -> None:
@@ -92,9 +92,9 @@ class PhpProvider:
92
92
 
93
93
  def base_commands(self) -> Dict[str, str]:
94
94
  if _exists(self.path, "public/index.php"):
95
- return {"start": '"php -S localhost:8080 -t public"'}
95
+ return {"start": 'f"php -S localhost:{PORT} -t public"'}
96
96
  elif _exists(self.path, "index.php"):
97
- return {"start": '"php -S localhost:8080 -t ."'}
97
+ return {"start": 'f"php -S localhost:{PORT} -t ."'}
98
98
 
99
99
  def mounts(self) -> list[MountSpec]:
100
100
  return [
@@ -284,7 +284,7 @@ class PythonProvider:
284
284
  if not self.only_build:
285
285
  steps = ['workdir(app["build"])']
286
286
  else:
287
- steps = []
287
+ steps = ['workdir(temp["build"])']
288
288
 
289
289
  extra_deps = ", ".join([f"{dep}" for dep in self.extra_dependencies])
290
290
  has_requirements = _exists(self.path, "requirements.txt")
@@ -317,7 +317,7 @@ class PythonProvider:
317
317
  ]
318
318
  if not self.only_build:
319
319
  steps += [
320
- 'run(f"uv pip compile pyproject.toml --python-version={python_version} --universal --extra-index-url {python_extra_index_url} --index-url=https://pypi.org/simple --emit-index-url -o cross-requirements.txt", outputs=["cross-requirements.txt"]) if cross_platform else None',
320
+ 'run(f"uv pip compile pyproject.toml --python-version={python_version} --universal --extra-index-url {python_extra_index_url} --index-url=https://pypi.org/simple --emit-index-url --no-deps -o cross-requirements.txt", outputs=["cross-requirements.txt"]) if cross_platform else None',
321
321
  f'run(f"uvx pip install -r cross-requirements.txt {extra_deps} --target {{python_cross_packages_path}} --platform {{cross_platform}} --only-binary=:all: --python-version={{python_version}} --compile") if cross_platform else None',
322
322
  'run("rm cross-requirements.txt") if cross_platform else None',
323
323
  ]
@@ -337,7 +337,7 @@ class PythonProvider:
337
337
  ]
338
338
  if not self.only_build:
339
339
  steps += [
340
- 'run(f"uv pip compile requirements.txt --python-version={python_version} --universal --extra-index-url {python_extra_index_url} --index-url=https://pypi.org/simple --emit-index-url -o cross-requirements.txt", inputs=["requirements.txt"], outputs=["cross-requirements.txt"]) if cross_platform else None',
340
+ 'run(f"uv pip compile requirements.txt --python-version={python_version} --universal --extra-index-url {python_extra_index_url} --index-url=https://pypi.org/simple --emit-index-url --no-deps -o cross-requirements.txt", inputs=["requirements.txt"], outputs=["cross-requirements.txt"]) if cross_platform else None',
341
341
  f'run(f"uvx pip install -r cross-requirements.txt {extra_deps} --target {{python_cross_packages_path}} --platform {{cross_platform}} --only-binary=:all: --python-version={{python_version}} --compile") if cross_platform else None',
342
342
  'run("rm cross-requirements.txt") if cross_platform else None',
343
343
  ]
@@ -396,20 +396,20 @@ class PythonProvider:
396
396
  if self.server == PythonServer.Daphne and self.asgi_application:
397
397
  asgi_application = format_app_import(self.asgi_application)
398
398
  start_cmd = (
399
- f'"python -m daphne {asgi_application} --bind 0.0.0.0 --port 8000"'
399
+ f'f"python -m daphne {asgi_application} --bind 0.0.0.0 --port {{PORT}}"'
400
400
  )
401
401
  elif self.server == PythonServer.Uvicorn:
402
402
  if self.asgi_application:
403
403
  asgi_application = format_app_import(self.asgi_application)
404
- start_cmd = f'"python -m uvicorn {asgi_application} --host 0.0.0.0 --port 8000"'
404
+ start_cmd = f'f"python -m uvicorn {asgi_application} --host 0.0.0.0 --port {{PORT}}"'
405
405
  elif self.wsgi_application:
406
406
  wsgi_application = format_app_import(self.wsgi_application)
407
- start_cmd = f'"python -m uvicorn {wsgi_application} --interface=wsgi --host 0.0.0.0 --port 8000"'
407
+ start_cmd = f'f"python -m uvicorn {wsgi_application} --interface=wsgi --host 0.0.0.0 --port {{PORT}}"'
408
408
  # elif self.server == PythonServer.Gunicorn:
409
- # start_cmd = f'"python -m gunicorn {self.wsgi_application} --bind 0.0.0.0 --port 8000"'
409
+ # start_cmd = f'"fpython -m gunicorn {self.wsgi_application} --bind 0.0.0.0 --port {{PORT}}"'
410
410
  if not start_cmd:
411
411
  # We run the default runserver command if no server is specified
412
- start_cmd = '"python manage.py runserver 0.0.0.0:8000"'
412
+ start_cmd = 'f"python manage.py runserver 0.0.0.0:{PORT}"'
413
413
  migrate_cmd = '"python manage.py migrate"'
414
414
  return {"start": start_cmd, "after_deploy": migrate_cmd}
415
415
 
@@ -423,21 +423,21 @@ class PythonProvider:
423
423
  python_path = file_to_python_path(main_file)
424
424
  path = f"{python_path}:app"
425
425
  if self.server == PythonServer.Uvicorn:
426
- start_cmd = f'"python -m uvicorn {path} --host 0.0.0.0 --port 8000"'
426
+ start_cmd = f'f"python -m uvicorn {path} --host 0.0.0.0 --port {{PORT}}"'
427
427
  elif self.server == PythonServer.Hypercorn:
428
- start_cmd = f'"python -m hypercorn {path} --bind 0.0.0.0:8000"'
428
+ start_cmd = f'f"python -m hypercorn {path} --bind 0.0.0.0:{{PORT}}"'
429
429
  else:
430
430
  start_cmd = '"python -c \'print(\\"No start command detected, please provide a start command manually\\")\'"'
431
431
  return {"start": start_cmd}
432
432
 
433
433
  elif self.framework == PythonFramework.Streamlit:
434
- start_cmd = f'"python -m streamlit run {main_file} --server.port 8000 --server.address 0.0.0.0 --server.headless true"'
434
+ start_cmd = f'f"python -m streamlit run {main_file} --server.port {{PORT}} --server.address 0.0.0.0 --server.headless true"'
435
435
 
436
436
  elif self.framework == PythonFramework.Flask:
437
437
  python_path = file_to_python_path(main_file)
438
438
  path = f"{python_path}:app"
439
- # start_cmd = f'"python -m flask --app {path} run --debug --host 0.0.0.0 --port 8000"'
440
- start_cmd = f'"python -m uvicorn {path} --interface=wsgi --host 0.0.0.0 --port 8000"'
439
+ # start_cmd = f'f"python -m flask --app {path} run --debug --host 0.0.0.0 --port {{PORT}}"'
440
+ start_cmd = f'f"python -m uvicorn {path} --interface=wsgi --host 0.0.0.0 --port {{PORT}}"'
441
441
 
442
442
  elif self.framework == PythonFramework.MCP:
443
443
  contents = (self.path / main_file).read_text()
@@ -449,7 +449,7 @@ class PythonProvider:
449
449
  elif self.framework == PythonFramework.FastHTML:
450
450
  python_path = file_to_python_path(main_file)
451
451
  path = f"{python_path}:app"
452
- start_cmd = f'"python -m uvicorn {path} --host 0.0.0.0 --port 8000"'
452
+ start_cmd = f'f"python -m uvicorn {path} --host 0.0.0.0 --port {{PORT}}"'
453
453
 
454
454
  else:
455
455
  start_cmd = f'"python {main_file}"'
@@ -459,6 +459,7 @@ class PythonProvider:
459
459
  def mounts(self) -> list[MountSpec]:
460
460
  if self.only_build:
461
461
  return [
462
+ MountSpec("temp", attach_to_serve=False),
462
463
  MountSpec("local_venv", attach_to_serve=False),
463
464
  ]
464
465
  return [
@@ -486,7 +487,7 @@ class PythonProvider:
486
487
  env_vars["STREAMLIT_SERVER_HEADLESS"] = '"true"'
487
488
  elif self.framework == PythonFramework.MCP:
488
489
  env_vars["FASTMCP_HOST"] = '"0.0.0.0"'
489
- env_vars["FASTMCP_PORT"] = '"8000"'
490
+ env_vars["FASTMCP_PORT"] = 'PORT'
490
491
  return env_vars
491
492
 
492
493
  def services(self) -> list[ServiceSpec]:
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
4
  from typing import Dict, Optional
5
+ import json
6
+ import yaml
5
7
 
6
8
  from .base import (
7
9
  DetectResult,
@@ -16,9 +18,17 @@ from .base import (
16
18
 
17
19
 
18
20
  class StaticFileProvider:
21
+ config: Optional[dict] = None
22
+
19
23
  def __init__(self, path: Path, custom_commands: CustomCommands):
20
24
  self.path = path
21
25
  self.custom_commands = custom_commands
26
+ if (self.path / "Staticfile").exists():
27
+ try:
28
+ self.config = yaml.safe_load((self.path / "Staticfile").read_text())
29
+ except yaml.YAMLError as e:
30
+ print(f"Error loading Staticfile: {e}")
31
+ pass
22
32
 
23
33
  @classmethod
24
34
  def name(cls) -> str:
@@ -58,7 +68,7 @@ class StaticFileProvider:
58
68
  def build_steps(self) -> list[str]:
59
69
  return [
60
70
  'workdir(app["build"])',
61
- 'copy(".", ".", ignore=[".git"])'
71
+ 'copy({}, ".", ignore=[".git"])'.format(json.dumps(self.config and self.config.get("root") or "."))
62
72
  ]
63
73
 
64
74
  def prepare_steps(self) -> Optional[list[str]]:
@@ -69,7 +79,7 @@ class StaticFileProvider:
69
79
 
70
80
  def commands(self) -> Dict[str, str]:
71
81
  return {
72
- "start": '"static-web-server --root={} --log-level=info".format(app["serve"])'
82
+ "start": '"static-web-server --root={} --log-level=info --port={}".format(app["serve"], PORT)'
73
83
  }
74
84
 
75
85
  def mounts(self) -> list[MountSpec]:
@@ -70,7 +70,7 @@ class WordPressProvider(PhpProvider):
70
70
 
71
71
  def commands(self) -> Dict[str, str]:
72
72
  return {
73
- "start": '"php -S localhost:8080 -t ."',
73
+ "start": 'f"php -S localhost:{PORT} -t ."',
74
74
  "wp": '"php {}/wp-cli.phar --allow-root --path={}".format(assets[\"serve\"], app[\"serve\"])',
75
75
  "after_deploy": '"bash {}/wordpress-install.sh".format(assets["serve"])',
76
76
  }
@@ -0,0 +1,5 @@
1
+ __all__ = ["version", "version_info"]
2
+
3
+
4
+ version = "0.8.0"
5
+ version_info = (0, 8, 0, "final", 0)
@@ -0,0 +1,359 @@
1
+ import os
2
+ import random
3
+ import socket
4
+ import signal
5
+ import asyncio
6
+ import subprocess
7
+ import re
8
+ from pathlib import Path
9
+ from typing import List, NamedTuple
10
+ from dataclasses import dataclass
11
+
12
+ import pytest
13
+ import shutil
14
+ import contextlib
15
+ import aiohttp
16
+ from enum import Enum
17
+
18
+
19
+ class BuildMode(Enum):
20
+ Wasmer = "wasmer"
21
+ WasmerAndDocker = "docker"
22
+ Local = "local"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class HTTPRequest:
27
+ path: str
28
+ body_match: str
29
+ method: str = "GET"
30
+
31
+
32
+ class E2ECase(NamedTuple):
33
+ path: str
34
+ serve_pattern: str
35
+ http: List[HTTPRequest]
36
+ use_random_port: bool = True
37
+
38
+ def __str__(self):
39
+ return self.path
40
+
41
+ def __repr__(self):
42
+ return self.path
43
+
44
+
45
+ @pytest.mark.e2e
46
+ @pytest.mark.asyncio
47
+ @pytest.mark.parametrize(
48
+ "case",
49
+ [
50
+ # Simple PHP site that calls phpinfo()
51
+ E2ECase(
52
+ path="examples/php-nobuild",
53
+ serve_pattern=(
54
+ r"PHP 8\.3\.[0-9]+ Development Server \(http://localhost:[\d]+\) started"
55
+ ),
56
+ http=[HTTPRequest(path="/", body_match=r"PHP Version 8\.3\.[0-9]+")],
57
+ ),
58
+ # Simple PHP site that calls phpinfo() with no port
59
+ E2ECase(
60
+ path="examples/php-nobuild",
61
+ serve_pattern=(
62
+ r"PHP 8\.3\.[0-9]+ Development Server \(http://localhost:[\d]+\) started"
63
+ ),
64
+ http=[HTTPRequest(path="/", body_match=r"PHP Version 8\.3\.[0-9]+")],
65
+ use_random_port=False,
66
+ ),
67
+ # PHP API example with JSON at / and greeting endpoint
68
+ E2ECase(
69
+ path="examples/php-api",
70
+ serve_pattern=(
71
+ r"PHP 8\.3\.[0-9]+ Development Server \(http://localhost:[\d]+\) started"
72
+ ),
73
+ http=[
74
+ HTTPRequest(path="/", body_match=r"\"version\"\s*:\s*\"8\.3\.[0-9]+\""),
75
+ HTTPRequest(path="/api/greet/Alice", body_match=r"Hello, Alice!"),
76
+ ],
77
+ ),
78
+ # WordPress skeleton that echoes a simple string
79
+ E2ECase(
80
+ path="examples/php-wordpress",
81
+ serve_pattern=(
82
+ r"PHP 8\.3\.[0-9]+ Development Server \(http://localhost:[\d]+\) started"
83
+ ),
84
+ http=[HTTPRequest(path="/", body_match=r"WordPress")],
85
+ ),
86
+ # Static site copied as-is (no build step beyond copy)
87
+ E2ECase(
88
+ path="examples/static-nobuild",
89
+ # static-web-server banner varies; rely on HTTP check with generous pattern
90
+ serve_pattern=r"server is listening on",
91
+ http=[HTTPRequest(path="/", body_match=r"Test")],
92
+ ),
93
+ # Staticfile provider serving content under site/
94
+ E2ECase(
95
+ path="examples/staticfile",
96
+ serve_pattern=r"server is listening on",
97
+ http=[HTTPRequest(path="/", body_match=r"Hello from static site!")],
98
+ ),
99
+ # Hugo static site (built via Hugo, served with static-web-server)
100
+ E2ECase(
101
+ path="examples/hugo",
102
+ serve_pattern=r"server is listening on",
103
+ http=[HTTPRequest(path="/", body_match=r"My New Hugo Site")],
104
+ ),
105
+ # MkDocs site (built with mkdocs, served with static-web-server)
106
+ E2ECase(
107
+ path="examples/mkdocs",
108
+ serve_pattern=r"server is listening on",
109
+ http=[HTTPRequest(path="/", body_match=r"Welcome to MkDocs")],
110
+ ),
111
+ # MkDocs with plugins
112
+ E2ECase(
113
+ path="examples/mkdocs-with-plugins",
114
+ serve_pattern=r"server is listening on",
115
+ http=[HTTPRequest(path="/", body_match=r"Welcome to MkDocs with Plugins")],
116
+ ),
117
+ # Python FastAPI app on Uvicorn
118
+ E2ECase(
119
+ path="examples/python-fastapi",
120
+ serve_pattern=r"Uvicorn running on .*",
121
+ http=[HTTPRequest(path="/", body_match=r"Hello World from fastapi!")],
122
+ ),
123
+ # Python Flask app served via Uvicorn WSGI
124
+ E2ECase(
125
+ path="examples/python-flask",
126
+ serve_pattern=r"Uvicorn running on .*",
127
+ http=[HTTPRequest(path="/", body_match=r"Welcome to Flask")],
128
+ ),
129
+ # Python Django via Uvicorn WSGI (check admin login)
130
+ E2ECase(
131
+ path="examples/python-django",
132
+ serve_pattern=r"Uvicorn running on .*",
133
+ http=[HTTPRequest(path="/", body_match=r"Django")],
134
+ ),
135
+ # Python ffmpeg demo (FastAPI), homepage is static HTML form
136
+ E2ECase(
137
+ path="examples/python-ffmpeg",
138
+ serve_pattern=r"Uvicorn running on .*",
139
+ http=[HTTPRequest(path="/", body_match=r"Take screenshot at 1s")],
140
+ ),
141
+ # Python Pillow demo (FastAPI), homepage has form title
142
+ E2ECase(
143
+ path="examples/python-pillow",
144
+ serve_pattern=r"Uvicorn running on .*",
145
+ http=[HTTPRequest(path="/", body_match=r"Image Crop\s*&\s*Rotate")],
146
+ ),
147
+ # Python Pandoc demo: app may require pandoc binary; only assert serve started
148
+ E2ECase(
149
+ path="examples/python-pandoc",
150
+ serve_pattern=r"Uvicorn running on .*",
151
+ http=[],
152
+ ),
153
+ # Python Procfile demo using python -m http.server
154
+ E2ECase(
155
+ path="examples/python-procfile",
156
+ serve_pattern=r"Serving HTTP on .*",
157
+ http=[HTTPRequest(path="/", body_match=r"Test")],
158
+ ),
159
+ # Python Streamlit app
160
+ E2ECase(
161
+ path="examples/python-streamlit",
162
+ serve_pattern=r".*You can now view your Streamlit app in your browser.*",
163
+ http=[HTTPRequest(path="/", body_match=r"Streamlit")],
164
+ ),
165
+ ],
166
+ ids=lambda c: str(c),
167
+ )
168
+ @pytest.mark.flaky(reruns=5, reruns_delay=2)
169
+ @pytest.mark.parametrize(
170
+ "build_mode",
171
+ [
172
+ BuildMode.Local,
173
+ BuildMode.Wasmer,
174
+ BuildMode.WasmerAndDocker,
175
+ ]
176
+ )
177
+ async def test_end_to_end(case: E2ECase, build_mode: BuildMode):
178
+ # Skip if `uv` is not available in PATH
179
+ if not shutil.which("uv"):
180
+ pytest.skip("`uv` is not available in PATH")
181
+
182
+ repo_root = Path(__file__).resolve().parents[1]
183
+
184
+ cmd = [
185
+ "uv",
186
+ "run",
187
+ "shipit-cli",
188
+ case.path,
189
+ "--skip-prepare",
190
+ "--start",
191
+ # "--wasmer",
192
+ # "--docker",
193
+ "--regenerate",
194
+ ]
195
+ if build_mode == BuildMode.Wasmer:
196
+ cmd.append("--wasmer")
197
+ elif build_mode == BuildMode.WasmerAndDocker:
198
+ cmd.append("--wasmer")
199
+ cmd.append("--docker")
200
+ elif build_mode == BuildMode.Local:
201
+ # The default
202
+ pass
203
+
204
+ build_phrase = "Build complete ✅"
205
+ serve_re = re.compile(case.serve_pattern)
206
+
207
+ # Start process in a new session/process group to simplify termination.
208
+ start_new_session = os.name != "nt"
209
+ creationflags = (
210
+ subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
211
+ )
212
+
213
+ env = os.environ.copy()
214
+ if case.use_random_port:
215
+ port = get_free_port()
216
+ env["PORT"] = str(port)
217
+ else:
218
+ port = 8080 # This is the default port if not specified
219
+
220
+ proc = await asyncio.create_subprocess_exec(
221
+ *cmd,
222
+ cwd=str(repo_root),
223
+ env=env,
224
+ stdout=asyncio.subprocess.PIPE,
225
+ stderr=asyncio.subprocess.PIPE,
226
+ start_new_session=start_new_session,
227
+ creationflags=creationflags,
228
+ )
229
+
230
+ output_lines: List[str] = []
231
+ found_build = asyncio.Event()
232
+ found_serve = asyncio.Event()
233
+
234
+ async def reader(label: str, stream: asyncio.StreamReader) -> None:
235
+ async for line in stream:
236
+ line = line.decode("utf-8", errors="replace")
237
+ print(f"[{label}] {line}", end='')
238
+ output_lines.append(f"[{label}] {line}")
239
+ if (not found_build.is_set()) and (build_phrase in line):
240
+ found_build.set()
241
+ if (not found_serve.is_set()) and serve_re.search(line):
242
+ found_serve.set()
243
+
244
+ assert proc.stdout is not None and proc.stderr is not None
245
+ reader_out_task = asyncio.create_task(reader("stdout", proc.stdout))
246
+ reader_err_task = asyncio.create_task(reader("stderr", proc.stderr))
247
+
248
+ try:
249
+ # Wait until both events are seen, the process exits, or timeout elapses.
250
+ loop = asyncio.get_running_loop()
251
+ end = loop.time() + 180
252
+ while loop.time() < end:
253
+ if found_build.is_set() and found_serve.is_set():
254
+ break
255
+ if proc.returncode is not None:
256
+ # Process ended early; stop waiting
257
+ break
258
+ await asyncio.sleep(0.05)
259
+
260
+ # If we saw the serve banner, exercise the HTTP endpoint before shutting
261
+ # down to ensure it actually serves content.
262
+ if found_serve.is_set():
263
+ for req in case.http:
264
+ ok = await _wait_for_http_contains(
265
+ host="localhost",
266
+ port=port,
267
+ method=req.method,
268
+ path=req.path,
269
+ pattern=req.body_match,
270
+ timeout=20.0,
271
+ )
272
+ if not ok:
273
+ full_output = "".join(output_lines)
274
+ pytest.fail(
275
+ "Server did not return expected HTTP content.\n\n"
276
+ f"Expected body regex: '{req.body_match}' at path '{req.path}'\n\n"
277
+ f"--- Captured output start ---\n{full_output}\n"
278
+ "--- Captured output end ---"
279
+ )
280
+ finally:
281
+ # Try graceful shutdown first with Ctrl-C (SIGINT), then kill if needed
282
+ try:
283
+ if os.name != "nt":
284
+ os.killpg(os.getpgid(proc.pid), signal.SIGINT)
285
+ else:
286
+ # For Windows with CREATE_NEW_PROCESS_GROUP
287
+ proc.send_signal(signal.CTRL_BREAK_EVENT)
288
+ except Exception:
289
+ pass
290
+
291
+ # Wait briefly for graceful exit, then force kill if still running
292
+ try:
293
+ await asyncio.wait_for(proc.wait(), timeout=2)
294
+ except asyncio.TimeoutError:
295
+ if os.name != "nt":
296
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
297
+ else:
298
+ proc.kill()
299
+
300
+ # Ensure reader tasks are finished
301
+ for t in (reader_out_task, reader_err_task):
302
+ if not t.done():
303
+ t.cancel()
304
+ for t in (reader_out_task, reader_err_task):
305
+ with contextlib.suppress(asyncio.CancelledError):
306
+ await t
307
+
308
+ full_output = "".join(output_lines)
309
+
310
+ if not (found_build.is_set() and found_serve.is_set()):
311
+ code = proc.returncode
312
+ pytest.fail(
313
+ "End-to-end run did not reach expected state.\n"
314
+ f"returncode={code}\n"
315
+ f"Saw build={found_build.is_set()} serve={found_serve.is_set()}\n\n"
316
+ f"--- Captured output start ---\n{full_output}\n--- Captured output end ---"
317
+ )
318
+
319
+ assert build_phrase in full_output
320
+ assert serve_re.search(full_output), "Serve banner regex not found in output"
321
+
322
+
323
+ async def _wait_for_http_contains(
324
+ host: str,
325
+ port: int,
326
+ method: str = "GET",
327
+ path: str = "/",
328
+ pattern: str = "",
329
+ timeout: float = 15.0,
330
+ ) -> bool:
331
+ url = f"http://{host}:{port}{path}"
332
+ loop = asyncio.get_running_loop()
333
+ end = loop.time() + timeout
334
+ async with aiohttp.ClientSession(
335
+ timeout=aiohttp.ClientTimeout(total=5.0)
336
+ ) as session:
337
+ while loop.time() < end:
338
+ try:
339
+ async with session.request(method, url) as resp:
340
+ text = await resp.text()
341
+ if re.search(pattern, text):
342
+ return True
343
+ except Exception:
344
+ # Not ready yet; retry shortly.
345
+ pass
346
+ await asyncio.sleep(0.2)
347
+ return False
348
+
349
+
350
+ def get_free_port(min_port=1024, max_port=65535):
351
+ while True:
352
+ port = random.randint(min_port, max_port)
353
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
354
+ try:
355
+ s.bind(("", port)) # Bind to the port on all interfaces
356
+ return port
357
+ except OSError:
358
+ # Port is already in use, try another one
359
+ continue
@@ -5,6 +5,8 @@ from pathlib import Path
5
5
  import pytest
6
6
 
7
7
  from shipit.generator import generate_shipit
8
+ from shipit.procfile import Procfile
9
+ from shipit.providers.base import CustomCommands
8
10
 
9
11
 
10
12
  def _example_dirs_with_shipit() -> list[Path]:
@@ -25,7 +27,12 @@ def test_generate_shipit_matches_example(example_dir: Path) -> None:
25
27
  This validates provider detection and the Shipit generator formatting for
26
28
  each example that includes a `Shipit` file.
27
29
  """
28
- generated = generate_shipit(example_dir)
30
+ custom_commands = CustomCommands()
31
+ if (example_dir / "Procfile").exists():
32
+ procfile = Procfile.loads((example_dir / "Procfile").read_text())
33
+ custom_commands.start = procfile.get_start_command()
34
+
35
+ generated = generate_shipit(example_dir, custom_commands=custom_commands)
29
36
  expected = (example_dir / "Shipit").read_text()
30
37
  # Use raw assert to let pytest show a unified diff on mismatch
31
38
  assert generated == expected
@@ -1,30 +0,0 @@
1
- import shlex
2
- import re
3
-
4
- def parse(content):
5
- """
6
- Parse the content of a .env file (a line-delimited KEY=value format) into a
7
- dictionary mapping keys to values.
8
- """
9
- values = {}
10
- for line in content.splitlines():
11
- lexer = shlex.shlex(line, posix=True)
12
- tokens = list(lexer)
13
-
14
- # parses the assignment statement
15
- if len(tokens) < 3:
16
- continue
17
-
18
- name, op = tokens[:2]
19
- value = ''.join(tokens[2:])
20
-
21
- if op != '=':
22
- continue
23
- if not re.match(r'[A-Za-z_][A-Za-z_0-9]*', name):
24
- continue
25
-
26
- value = value.replace(r'\n', '\n')
27
- value = value.replace(r'\t', '\t')
28
- values[name] = value
29
-
30
- return values
@@ -1,5 +0,0 @@
1
- __all__ = ["version", "version_info"]
2
-
3
-
4
- version = "0.7.0"
5
- version_info = (0, 7, 0, "final", 0)
@@ -1,45 +0,0 @@
1
- from pathlib import Path
2
- from typing import List
3
-
4
- import pytest
5
- from typer.testing import CliRunner
6
-
7
- import shutil
8
-
9
- from shipit.cli import app, LocalBuilder
10
-
11
-
12
- def list_example_dirs() -> List[Path]:
13
- root = Path(__file__).resolve().parents[1]
14
- examples_dir = root / "examples"
15
- result: List[Path] = []
16
- if not examples_dir.exists():
17
- return result
18
- for path in sorted(examples_dir.iterdir()):
19
- if path.is_dir() and (path / "Shipit").exists():
20
- result.append(path)
21
- return result
22
-
23
-
24
- @pytest.mark.parametrize("example_dir", list_example_dirs(), ids=lambda p: p.name)
25
- def test_shipit_build_examples_noop_commands(monkeypatch: pytest.MonkeyPatch, example_dir: Path) -> None:
26
- # Make all external commands no-ops to avoid network and toolchain deps.
27
- # 1) Pretend every required program exists by resolving to a benign executable.
28
- true_exe = shutil.which("true") or "/usr/bin/true"
29
- monkeypatch.setattr(shutil, "which", lambda *_args, **_kwargs: true_exe)
30
- # 2) Neutralize any builder-level command invocations (e.g., wasmer during prepare).
31
- monkeypatch.setattr(LocalBuilder, "run_command", lambda self, command, extra_args=None: None, raising=True)
32
-
33
- runner = CliRunner()
34
- # Use --wasmer to ensure examples that rely on cross-platform env build.
35
- result = runner.invoke(app, ["build", "--wasmer", str(example_dir)])
36
-
37
- # Basic sanity: command runs successfully
38
- assert result.exit_code == 0, result.output
39
-
40
- # Stable output lines from Shipit during a successful build
41
- out = result.output
42
- # Stable, provider-agnostic output
43
- assert "Shipit" in out
44
- assert "Building package" in out
45
- assert "Build complete" in out
File without changes
File without changes