shipit-cli 0.7.1__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.
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/PKG-INFO +1 -1
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/pyproject.toml +8 -1
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/assets/wordpress/install.sh +4 -9
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/cli.py +30 -28
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/generator.py +14 -2
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/providers/hugo.py +6 -2
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/providers/laravel.py +1 -1
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/providers/mkdocs.py +2 -0
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/providers/php.py +2 -2
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/providers/python.py +16 -15
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/providers/staticfile.py +12 -2
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/providers/wordpress.py +1 -1
- shipit_cli-0.8.0/src/shipit/version.py +5 -0
- shipit_cli-0.8.0/tests/test_e2e.py +359 -0
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/tests/test_generate_shipit_examples.py +8 -1
- shipit_cli-0.7.1/src/shipit/version.py +0 -5
- shipit_cli-0.7.1/tests/test_examples_build.py +0 -45
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/.gitignore +0 -0
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/README.md +0 -0
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/__init__.py +0 -0
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/assets/php/php.ini +0 -0
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/assets/wordpress/wp-config.php +0 -0
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/procfile.py +0 -0
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/providers/base.py +0 -0
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/providers/gatsby.py +0 -0
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/providers/node_static.py +0 -0
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/src/shipit/providers/registry.py +0 -0
- {shipit_cli-0.7.1 → shipit_cli-0.8.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "shipit-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
description = "Add your description here"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -35,5 +35,12 @@ only-include = ["src/shipit", "tests"]
|
|
|
35
35
|
|
|
36
36
|
[tool.uv]
|
|
37
37
|
dev-dependencies = [
|
|
38
|
+
"aiohttp>=3.12.15",
|
|
38
39
|
"pytest>=8.2",
|
|
40
|
+
"pytest-asyncio>=1.2.0",
|
|
41
|
+
"pytest-rerunfailures>=16.0.1",
|
|
42
|
+
"pytest-xdist>=3.8.0",
|
|
39
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
|
|
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 "$
|
|
25
|
-
wp
|
|
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"
|
|
@@ -148,10 +148,12 @@ class Build:
|
|
|
148
148
|
|
|
149
149
|
def write_stdout(line: str) -> None:
|
|
150
150
|
sys.stdout.write(line) # print to console
|
|
151
|
+
sys.stdout.flush()
|
|
151
152
|
|
|
152
153
|
|
|
153
154
|
def write_stderr(line: str) -> None:
|
|
154
155
|
sys.stderr.write(line) # print to console
|
|
156
|
+
sys.stderr.flush()
|
|
155
157
|
|
|
156
158
|
|
|
157
159
|
class MapperItem(TypedDict):
|
|
@@ -179,6 +181,16 @@ class Builder(Protocol):
|
|
|
179
181
|
|
|
180
182
|
|
|
181
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
|
+
|
|
182
194
|
def __init__(self, src_dir: Path, docker_client: Optional[str] = None) -> None:
|
|
183
195
|
self.src_dir = src_dir
|
|
184
196
|
self.docker_file_contents = ""
|
|
@@ -340,12 +352,17 @@ RUN chmod {oct(mode)[2:]} {path.absolute()}
|
|
|
340
352
|
)
|
|
341
353
|
self.docker_file_contents += f"RUN curl --proto '=https' --tlsv1.2 -sSfL https://get.static-web-server.net | sh\n"
|
|
342
354
|
return
|
|
355
|
+
|
|
356
|
+
mapped_dependency = self.mise_mapper.get(dependency.name, {})
|
|
357
|
+
package_name = mapped_dependency.get("source", dependency.name)
|
|
343
358
|
if dependency.version:
|
|
344
359
|
self.docker_file_contents += (
|
|
345
|
-
f"RUN mise use --global {
|
|
360
|
+
f"RUN mise use --global {package_name}@{dependency.version}\n"
|
|
346
361
|
)
|
|
347
362
|
else:
|
|
348
|
-
self.docker_file_contents += f"RUN mise use --global {
|
|
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"
|
|
349
366
|
|
|
350
367
|
def build(
|
|
351
368
|
self, env: Dict[str, str], mounts: List[Mount], steps: List[Step]
|
|
@@ -358,8 +375,8 @@ RUN chmod {oct(mode)[2:]} {path.absolute()}
|
|
|
358
375
|
self.docker_file_contents += """
|
|
359
376
|
RUN apt-get update \\
|
|
360
377
|
&& apt-get -y --no-install-recommends install \\
|
|
361
|
-
build-essential gcc make \\
|
|
362
|
-
dpkg-dev pkg-config \\
|
|
378
|
+
build-essential gcc make autoconf libtool bison \\
|
|
379
|
+
dpkg-dev pkg-config re2c locate \\
|
|
363
380
|
libmariadb-dev libmariadb-dev-compat libpq-dev \\
|
|
364
381
|
sudo curl ca-certificates \\
|
|
365
382
|
&& rm -rf /var/lib/apt/lists/*
|
|
@@ -407,7 +424,7 @@ RUN curl https://mise.run | sh
|
|
|
407
424
|
# Read the file content and write it to the target file
|
|
408
425
|
content_base64 = base64.b64encode(
|
|
409
426
|
(ASSETS_PATH / step.source).read_bytes()
|
|
410
|
-
)
|
|
427
|
+
).decode("utf-8")
|
|
411
428
|
self.docker_file_contents += (
|
|
412
429
|
f"RUN echo '{content_base64}' | base64 -d > {step.target}\n"
|
|
413
430
|
)
|
|
@@ -446,12 +463,6 @@ Shipit
|
|
|
446
463
|
def get_path(self) -> Path:
|
|
447
464
|
return Path("/")
|
|
448
465
|
|
|
449
|
-
def get_build_path(self) -> Path:
|
|
450
|
-
return self.get_path() / "app"
|
|
451
|
-
|
|
452
|
-
def get_serve_path(self) -> Path:
|
|
453
|
-
return self.get_path() / "serve"
|
|
454
|
-
|
|
455
466
|
def prepare(self, env: Dict[str, str], prepare: List[PrepareStep]) -> None:
|
|
456
467
|
raise NotImplementedError
|
|
457
468
|
|
|
@@ -461,13 +472,12 @@ Shipit
|
|
|
461
472
|
for dep in serve.deps:
|
|
462
473
|
self.add_dependency(dep)
|
|
463
474
|
|
|
464
|
-
build_path = self.get_build_path()
|
|
465
475
|
for command in serve.commands:
|
|
466
476
|
console.print(f"* {command}")
|
|
467
477
|
command_path = serve_command_path / command
|
|
468
478
|
self.create_file(
|
|
469
479
|
command_path,
|
|
470
|
-
f"#!/bin/bash\ncd {
|
|
480
|
+
f"#!/bin/bash\ncd {serve.cwd}\n{serve.commands[command]}",
|
|
471
481
|
mode=0o755,
|
|
472
482
|
)
|
|
473
483
|
|
|
@@ -480,6 +490,7 @@ class LocalBuilder:
|
|
|
480
490
|
def __init__(self, src_dir: Path) -> None:
|
|
481
491
|
self.src_dir = src_dir
|
|
482
492
|
self.local_path = self.src_dir / ".shipit" / "local"
|
|
493
|
+
self.serve_bin_path = self.local_path / "serve" / "bin"
|
|
483
494
|
self.prepare_bash_script = self.local_path / "prepare" / "prepare.sh"
|
|
484
495
|
self.build_path = self.local_path / "build"
|
|
485
496
|
self.workdir = self.build_path
|
|
@@ -503,6 +514,8 @@ class LocalBuilder:
|
|
|
503
514
|
elif isinstance(step, WorkdirStep):
|
|
504
515
|
console.print(f"[bold]Working in {step.path}[/bold]")
|
|
505
516
|
self.workdir = step.path
|
|
517
|
+
# We make sure the dir exists
|
|
518
|
+
step.path.mkdir(parents=True, exist_ok=True)
|
|
506
519
|
elif isinstance(step, RunStep):
|
|
507
520
|
extra = ""
|
|
508
521
|
if step.inputs:
|
|
@@ -628,12 +641,6 @@ class LocalBuilder:
|
|
|
628
641
|
def get_path(self) -> Path:
|
|
629
642
|
return self.local_path
|
|
630
643
|
|
|
631
|
-
def get_build_path(self) -> Path:
|
|
632
|
-
return self.get_path() / "build"
|
|
633
|
-
|
|
634
|
-
def get_serve_path(self) -> Path:
|
|
635
|
-
return self.get_path() / "serve"
|
|
636
|
-
|
|
637
644
|
def build_prepare(self, serve: Serve) -> None:
|
|
638
645
|
self.prepare_bash_script.parent.mkdir(parents=True, exist_ok=True)
|
|
639
646
|
commands: List[str] = []
|
|
@@ -676,14 +683,13 @@ class LocalBuilder:
|
|
|
676
683
|
def build_serve(self, serve: Serve) -> None:
|
|
677
684
|
# Remember serve configuration for run-time
|
|
678
685
|
console.print("\n[bold]Building serve[/bold]")
|
|
679
|
-
|
|
680
|
-
serve_command_path.mkdir(parents=True, exist_ok=False)
|
|
686
|
+
self.serve_bin_path.mkdir(parents=True, exist_ok=False)
|
|
681
687
|
path = self.get_path() / ".path"
|
|
682
688
|
path_text = path.read_text()
|
|
683
689
|
console.print(f"[bold]Serve Commands:[/bold]")
|
|
684
690
|
for command in serve.commands:
|
|
685
691
|
console.print(f"* {command}")
|
|
686
|
-
command_path =
|
|
692
|
+
command_path = self.serve_bin_path / command
|
|
687
693
|
env_vars = ""
|
|
688
694
|
if serve.env:
|
|
689
695
|
env_vars = " ".join([f"{k}={v}" for k, v in serve.env.items()])
|
|
@@ -707,8 +713,7 @@ class LocalBuilder:
|
|
|
707
713
|
|
|
708
714
|
def run_serve_command(self, command: str) -> None:
|
|
709
715
|
console.print(f"\n[bold]Running {command} command[/bold]")
|
|
710
|
-
|
|
711
|
-
command_path = base_path / command
|
|
716
|
+
command_path = self.serve_bin_path / command
|
|
712
717
|
sh.Command(str(command_path))(_out=write_stdout, _err=write_stderr)
|
|
713
718
|
|
|
714
719
|
|
|
@@ -808,9 +813,6 @@ class WasmerBuilder:
|
|
|
808
813
|
) -> None:
|
|
809
814
|
return self.inner_builder.build(env, mounts, build)
|
|
810
815
|
|
|
811
|
-
def get_build_path(self) -> Path:
|
|
812
|
-
return Path("/app")
|
|
813
|
-
|
|
814
816
|
def build_prepare(self, serve: Serve) -> None:
|
|
815
817
|
print("Building prepare")
|
|
816
818
|
prepare_dir = self.wasmer_dir_path / "prepare"
|
|
@@ -1704,7 +1706,7 @@ def main() -> None:
|
|
|
1704
1706
|
app()
|
|
1705
1707
|
except Exception as e:
|
|
1706
1708
|
console.print(f"[bold red]{type(e).__name__}[/bold red]: {e}")
|
|
1707
|
-
raise e
|
|
1709
|
+
# raise e
|
|
1708
1710
|
|
|
1709
1711
|
|
|
1710
1712
|
if __name__ == "__main__":
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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:
|
|
95
|
+
return {"start": 'f"php -S localhost:{PORT} -t public"'}
|
|
96
96
|
elif _exists(self.path, "index.php"):
|
|
97
|
-
return {"start": '"php -S localhost:
|
|
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
|
|
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
|
|
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
|
|
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'"
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
440
|
-
start_cmd = f'"python -m uvicorn {path} --interface=wsgi --host 0.0.0.0 --port
|
|
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
|
|
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"] = '
|
|
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(
|
|
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:
|
|
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,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
|
-
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|