shipit-cli 0.14.0__py3-none-any.whl → 0.15.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,10 @@
1
- from __future__ import annotations
2
-
3
- import json
4
1
  import re
5
2
  from pathlib import Path
6
3
  from typing import Dict, Optional, Set
7
4
  from enum import Enum
8
- from functools import cached_property
5
+
6
+ from pydantic import Field
7
+ from pydantic_settings import SettingsConfigDict
9
8
 
10
9
  from .base import (
11
10
  DetectResult,
@@ -15,7 +14,7 @@ from .base import (
15
14
  MountSpec,
16
15
  ServiceSpec,
17
16
  VolumeSpec,
18
- CustomCommands,
17
+ Config,
19
18
  )
20
19
 
21
20
 
@@ -40,39 +39,61 @@ class DatabaseType(Enum):
40
39
  PostgreSQL = "postgresql"
41
40
 
42
41
 
43
- class PythonProvider:
42
+ class PythonConfig(Config):
43
+ model_config = SettingsConfigDict(
44
+ extra="ignore", env_prefix="SHIPIT_"
45
+ )
46
+
44
47
  framework: Optional[PythonFramework] = None
45
48
  server: Optional[PythonServer] = None
46
49
  database: Optional[DatabaseType] = None
47
- extra_dependencies: Set[str]
50
+ extra_dependencies: Set[str] = Field(default_factory=set)
48
51
  asgi_application: Optional[str] = None
49
52
  wsgi_application: Optional[str] = None
50
53
  uses_ffmpeg: bool = False
51
54
  uses_pandoc: bool = False
52
- only_build: bool = False
53
55
  install_requires_all_files: bool = False
54
- custom_commands: CustomCommands
56
+ main_file: Optional[str] = None
57
+ python_version: Optional[str] = "3.13"
58
+ uv_version: Optional[str] = "0.8.15"
59
+ precompile_python: bool = True
60
+ cross_platform: Optional[str] = None
61
+ python_extra_index_url: Optional[str] = None
62
+ pandoc_version: Optional[str] = None
63
+ ffmpeg_version: Optional[str] = None
64
+
65
+
66
+ class PythonProvider:
67
+ only_build: bool = False
55
68
 
56
69
  def __init__(
57
70
  self,
58
71
  path: Path,
59
- custom_commands: CustomCommands,
72
+ config: PythonConfig,
60
73
  only_build: bool = False,
61
- extra_dependencies: Optional[Set[str]] = None,
62
74
  ):
63
75
  self.path = path
64
- if _exists(self.path, ".python-version"):
65
- python_version = (self.path / ".python-version").read_text().strip()
66
- else:
67
- python_version = "3.13"
68
- self.default_python_version = python_version
69
- self.extra_dependencies = set()
76
+ self.config = config
70
77
  self.only_build = only_build
71
- self.custom_commands = custom_commands
72
- self.extra_dependencies = extra_dependencies or set()
73
78
 
74
- if self.only_build:
75
- return
79
+ @classmethod
80
+ def load_config(
81
+ cls,
82
+ path: Path,
83
+ base_config: Config,
84
+ must_have_deps: Optional[Set[str]] = None,
85
+ ) -> PythonConfig:
86
+ config = PythonConfig(**base_config.model_dump())
87
+
88
+ if not config.main_file:
89
+ config.main_file = cls.detect_main_file(path)
90
+
91
+ if not config.python_version:
92
+ if _exists(path, ".python-version"):
93
+ python_version = (path / ".python-version").read_text().strip()
94
+ else:
95
+ python_version = "3.13"
96
+ config.python_version = python_version
76
97
 
77
98
  pg_deps = {
78
99
  "asyncpg",
@@ -82,12 +103,21 @@ class PythonProvider:
82
103
  "psycopg-binary",
83
104
  "psycopg2-binary",
84
105
  }
85
- mysql_deps = {"mysqlclient", "pymysql", "mysql-connector-python", "aiomysql", "asyncmy"}
86
- found_deps = self.check_deps(
106
+ mysql_deps = {
107
+ "mysqlclient",
108
+ "pymysql",
109
+ "mysql-connector-python",
110
+ "aiomysql",
111
+ "asyncmy",
112
+ }
113
+ must_have_deps = must_have_deps or set()
114
+ found_deps = cls.check_deps(
115
+ path,
87
116
  "file://", # This is not really a dependency, but as a way to check if the install script requires all files
88
117
  "streamlit",
89
118
  "django",
90
119
  "mcp",
120
+ "mcp[cli]",
91
121
  "fastapi",
92
122
  "flask",
93
123
  "python-fasthtml",
@@ -100,100 +130,135 @@ class PythonProvider:
100
130
  # "gunicorn",
101
131
  *mysql_deps,
102
132
  *pg_deps,
133
+ *must_have_deps,
103
134
  )
104
135
 
105
136
  if "file://" in found_deps:
106
- self.install_requires_all_files = True
107
-
108
- # ASGI/WSGI Server
109
- if "uvicorn" in found_deps:
110
- server = PythonServer.Uvicorn
111
- elif "hypercorn" in found_deps:
112
- server = PythonServer.Hypercorn
113
- # elif "gunicorn" in found_deps:
114
- # server = PythonServer.Gunicorn
115
- elif "daphne" in found_deps:
116
- server = PythonServer.Daphne
117
- else:
118
- server = None
119
- self.server = server
137
+ config.install_requires_all_files = True
138
+
139
+ if not config.server:
140
+ # ASGI/WSGI Server
141
+ if "uvicorn" in found_deps:
142
+ server = PythonServer.Uvicorn
143
+ elif "hypercorn" in found_deps:
144
+ server = PythonServer.Hypercorn
145
+ # elif "gunicorn" in found_deps:
146
+ # server = PythonServer.Gunicorn
147
+ elif "daphne" in found_deps:
148
+ server = PythonServer.Daphne
149
+ else:
150
+ server = None
151
+ config.server = server
120
152
 
121
153
  if "ffmpeg" in found_deps:
122
- self.uses_ffmpeg = True
154
+ config.uses_ffmpeg = True
123
155
  if "pandoc" in found_deps:
124
- self.uses_pandoc = True
125
-
126
- if self.custom_commands.start and self.custom_commands.start.startswith("uvicorn "):
127
- self.server = PythonServer.Uvicorn
128
- self.custom_commands.start = self.custom_commands.start.replace("uvicorn ", "python -m uvicorn ")
129
- self.extra_dependencies = {"uvicorn"}
130
-
131
- # Set framework
132
- if _exists(self.path, "manage.py") and ("django" in found_deps):
133
- framework = PythonFramework.Django
134
- # Find the settings.py file using glob
135
- try:
136
- settings_file = next(self.path.glob("**/settings.py"))
137
- except StopIteration:
138
- settings_file = None
139
- if settings_file:
140
- asgi_match = re.search(
141
- r"ASGI_APPLICATION\s*=\s*['\"](.*)['\"]", settings_file.read_text()
142
- )
143
- if asgi_match:
144
- self.asgi_application = asgi_match.group(1)
145
- else:
146
- wsgi_match = re.search(
147
- r"WSGI_APPLICATION\s*=\s*['\"](.*)['\"]",
156
+ config.uses_pandoc = True
157
+
158
+ if not config.framework:
159
+ # Set framework
160
+ if _exists(path, "manage.py") and ("django" in found_deps):
161
+ framework = PythonFramework.Django
162
+ elif "streamlit" in found_deps:
163
+ framework = PythonFramework.Streamlit
164
+ elif "mcp" in found_deps:
165
+ framework = PythonFramework.MCP
166
+ elif "fastapi" in found_deps:
167
+ framework = PythonFramework.FastAPI
168
+ elif "flask" in found_deps:
169
+ framework = PythonFramework.Flask
170
+ elif "python-fasthtml" in found_deps:
171
+ framework = PythonFramework.FastHTML
172
+ else:
173
+ framework = None
174
+ config.framework = framework
175
+
176
+ if not config.server and config.framework:
177
+ if config.framework == PythonFramework.Django:
178
+ config.server = PythonServer.Uvicorn
179
+ elif config.framework == PythonFramework.FastAPI:
180
+ config.server = PythonServer.Uvicorn
181
+ elif config.framework == PythonFramework.Flask:
182
+ config.server = PythonServer.Uvicorn
183
+ elif config.framework == PythonFramework.FastHTML:
184
+ config.server = PythonServer.Uvicorn
185
+
186
+ if config.server == PythonServer.Uvicorn:
187
+ must_have_deps.add("uvicorn")
188
+
189
+ if not config.asgi_application and not config.wsgi_application:
190
+ if config.framework == PythonFramework.Django:
191
+ # Find the settings.py file using glob
192
+ try:
193
+ settings_file = next(path.glob("**/settings.py"))
194
+ except StopIteration:
195
+ settings_file = None
196
+ if settings_file:
197
+ asgi_match = re.search(
198
+ r"ASGI_APPLICATION\s*=\s*['\"](.*)['\"]",
148
199
  settings_file.read_text(),
149
200
  )
150
- if wsgi_match:
151
- self.wsgi_application = wsgi_match.group(1)
152
-
153
- if not self.server:
154
- if self.asgi_application:
155
- self.extra_dependencies = {"uvicorn"}
156
- self.server = PythonServer.Uvicorn
157
- elif self.wsgi_application:
158
- # gunicorn can't run with Wasmer atm
159
- self.extra_dependencies = {"uvicorn"}
160
- self.server = PythonServer.Uvicorn
161
- elif "streamlit" in found_deps:
162
- framework = PythonFramework.Streamlit
163
- elif "mcp" in found_deps:
164
- framework = PythonFramework.MCP
165
- self.extra_dependencies = {"mcp[cli]"}
166
- elif "fastapi" in found_deps:
167
- framework = PythonFramework.FastAPI
168
- if not self.server:
169
- self.extra_dependencies = {"uvicorn"}
170
- self.server = PythonServer.Uvicorn
171
- elif "flask" in found_deps:
172
- framework = PythonFramework.Flask
173
- if not self.server:
174
- self.extra_dependencies = {"uvicorn"}
175
- self.server = PythonServer.Uvicorn
176
- elif "python-fasthtml" in found_deps:
177
- framework = PythonFramework.FastHTML
178
- else:
179
- framework = None
180
- self.framework = framework
181
-
182
- # Database
183
- if mysql_deps & found_deps:
184
- database = DatabaseType.MySQL
185
- elif pg_deps & found_deps:
186
- database = DatabaseType.PostgreSQL
187
- else:
188
- database = None
189
- self.database = database
201
+ if asgi_match:
202
+ config.asgi_application = format_app_import(
203
+ asgi_match.group(1)
204
+ )
205
+ else:
206
+ wsgi_match = re.search(
207
+ r"WSGI_APPLICATION\s*=\s*['\"](.*)['\"]",
208
+ settings_file.read_text(),
209
+ )
210
+ if wsgi_match:
211
+ config.wsgi_application = format_app_import(
212
+ wsgi_match.group(1)
213
+ )
214
+
215
+ python_path = file_to_python_path(config.main_file)
216
+ if config.framework == PythonFramework.FastAPI:
217
+ config.asgi_application = python_path
218
+ elif config.framework == PythonFramework.Flask:
219
+ config.wsgi_application = python_path
220
+ elif config.framework == PythonFramework.MCP:
221
+ config.asgi_application = python_path
222
+ elif config.framework == PythonFramework.FastHTML:
223
+ config.asgi_application = python_path
224
+
225
+ is_uvicorn_start = config.commands.start and config.commands.start.startswith(
226
+ "uvicorn "
227
+ )
228
+ framework_should_use_uvicorn = config.framework in [
229
+ PythonFramework.Django,
230
+ PythonFramework.FastAPI,
231
+ PythonFramework.Flask,
232
+ ]
233
+ if is_uvicorn_start or (framework_should_use_uvicorn and not config.server):
234
+ must_have_deps.add("uvicorn")
235
+ config.server = PythonServer.Uvicorn
236
+ if config.framework == PythonFramework.MCP:
237
+ must_have_deps.add("mcp[cli]")
238
+
239
+ for dep in must_have_deps:
240
+ if dep not in found_deps:
241
+ config.extra_dependencies.add(dep)
242
+
243
+ if not config.database:
244
+ # Database
245
+ if mysql_deps & found_deps:
246
+ database = DatabaseType.MySQL
247
+ elif pg_deps & found_deps:
248
+ database = DatabaseType.PostgreSQL
249
+ else:
250
+ database = None
251
+ config.database = database
252
+
253
+ return config
190
254
 
191
- def check_deps(self, *deps: str) -> Set[str]:
255
+ @classmethod
256
+ def check_deps(cls, path: Path, *deps: str) -> Set[str]:
192
257
  deps = set([dep.lower() for dep in deps])
193
258
  initial_deps = set(deps)
194
259
  for file in ["requirements.txt", "pyproject.toml"]:
195
- if _exists(self.path, file):
196
- for line in (self.path / file).read_text().splitlines():
260
+ if _exists(path, file):
261
+ for line in (path / file).read_text().splitlines():
197
262
  for dep in set(deps):
198
263
  if dep in line.lower():
199
264
  deps.remove(dep)
@@ -210,57 +275,56 @@ class PythonProvider:
210
275
  return "python"
211
276
 
212
277
  @classmethod
213
- def detect(cls, path: Path, custom_commands: CustomCommands) -> Optional[DetectResult]:
278
+ def detect(
279
+ cls, path: Path, config: Config
280
+ ) -> Optional[DetectResult]:
214
281
  if _exists(path, "pyproject.toml", "requirements.txt"):
215
282
  if _exists(path, "manage.py"):
216
283
  return DetectResult(cls.name(), 70)
217
284
  return DetectResult(cls.name(), 50)
218
- if custom_commands.start:
219
- if custom_commands.start.startswith("python ") or custom_commands.start.startswith("uv ") or custom_commands.start.startswith("uvicorn ") or custom_commands.start.startswith("gunicorn "):
285
+ if config.commands.start:
286
+ if (
287
+ config.commands.start.startswith("python ")
288
+ or config.commands.start.startswith("uv ")
289
+ or config.commands.start.startswith("uvicorn ")
290
+ or config.commands.start.startswith("gunicorn ")
291
+ ):
220
292
  return DetectResult(cls.name(), 80)
221
293
  if cls.detect_main_file(path):
222
294
  return DetectResult(cls.name(), 10)
223
295
  return None
224
296
 
225
- def initialize(self) -> None:
226
- pass
227
-
228
297
  def serve_name(self) -> Optional[str]:
229
298
  return None
230
299
 
231
- def platform(self) -> Optional[str]:
232
- return self.framework.value if self.framework else None
233
-
234
300
  def dependencies(self) -> list[DependencySpec]:
235
301
  deps = [
236
302
  DependencySpec(
237
303
  "python",
238
- env_var="SHIPIT_PYTHON_VERSION",
239
- default_version=self.default_python_version,
304
+ var_name="config.python_version",
240
305
  use_in_build=True,
241
306
  use_in_serve=True,
242
307
  ),
243
308
  DependencySpec(
244
309
  "uv",
245
- env_var="SHIPIT_UV_VERSION",
246
- default_version="0.8.15",
310
+ var_name="config.uv_version",
247
311
  use_in_build=True,
248
312
  ),
249
313
  ]
250
- if self.uses_pandoc:
314
+ if self.config.uses_pandoc:
251
315
  deps.append(
252
316
  DependencySpec(
253
317
  "pandoc",
254
- env_var="SHIPIT_PANDOC_VERSION",
318
+ var_name="config.pandoc_version",
255
319
  use_in_build=False,
256
320
  use_in_serve=True,
257
321
  )
258
322
  )
259
- if self.uses_ffmpeg:
323
+ if self.config.uses_ffmpeg:
260
324
  deps.append(
261
325
  DependencySpec(
262
326
  "ffmpeg",
263
- env_var="SHIPIT_FFMPEG_VERSION",
327
+ var_name="config.ffmpeg_version",
264
328
  use_in_build=False,
265
329
  use_in_serve=True,
266
330
  )
@@ -270,25 +334,27 @@ class PythonProvider:
270
334
  def declarations(self) -> Optional[str]:
271
335
  if self.only_build:
272
336
  return (
273
- 'cross_platform = getenv("SHIPIT_PYTHON_CROSS_PLATFORM")\n'
337
+ "python_version = config.python_version\n"
338
+ "cross_platform = config.cross_platform\n"
274
339
  "venv = local_venv\n"
275
340
  )
276
341
  return (
277
- 'cross_platform = getenv("SHIPIT_PYTHON_CROSS_PLATFORM")\n'
278
- 'python_extra_index_url = getenv("SHIPIT_PYTHON_EXTRA_INDEX_URL")\n'
279
- 'precompile_python = getenv("SHIPIT_PYTHON_PRECOMPILE") in ["true", "True", "TRUE", "1", "on", "yes", "y", "Y", "YES", "On", "ON"]\n'
280
- 'python_cross_packages_path = venv["build"] + f"/lib/python{python_version}/site-packages"\n'
281
- 'python_serve_site_packages_path = "{}/lib/python{}/site-packages".format(venv["serve"], python_version)\n'
282
- 'app_serve_path = app["serve"]\n'
342
+ "python_version = config.python_version\n"
343
+ "cross_platform = config.cross_platform\n"
344
+ "python_extra_index_url = config.python_extra_index_url\n"
345
+ "precompile_python = config.precompile_python\n"
346
+ 'python_cross_packages_path = venv.path + f"/lib/python{python_version}/site-packages"\n'
347
+ 'python_serve_site_packages_path = "{}/lib/python{}/site-packages".format(venv.serve_path, python_version)\n'
348
+ 'app_serve_path = app.serve_path\n'
283
349
  )
284
350
 
285
351
  def build_steps(self) -> list[str]:
286
352
  if not self.only_build:
287
- steps = ['workdir(app["build"])']
353
+ steps = ['workdir(app.path)']
288
354
  else:
289
- steps = ['workdir(temp["build"])']
355
+ steps = ['workdir(temp.path)']
290
356
 
291
- extra_deps = ", ".join([f"{dep}" for dep in self.extra_dependencies])
357
+ extra_deps = ", ".join([f"{dep}" for dep in self.config.extra_dependencies])
292
358
  has_requirements = _exists(self.path, "requirements.txt")
293
359
  if _exists(self.path, "pyproject.toml"):
294
360
  input_files = ["pyproject.toml"]
@@ -309,11 +375,11 @@ class PythonProvider:
309
375
  # Join inputs
310
376
  inputs = ", ".join([f'"{input}"' for input in input_files])
311
377
  steps += [
312
- 'env(UV_PROJECT_ENVIRONMENT=local_venv["build"] if cross_platform else venv["build"], UV_PYTHON_PREFERENCE="only-system", UV_PYTHON=f"python{python_version}")',
313
- 'copy(".", ".")' if self.install_requires_all_files else None,
378
+ 'env(UV_PROJECT_ENVIRONMENT=local_venv.path if cross_platform else venv.path, UV_PYTHON_PREFERENCE="only-system", UV_PYTHON=f"python{python_version}")',
379
+ 'copy(".", ".")' if self.config.install_requires_all_files else None,
314
380
  f'run(f"uv sync{extra_args}", inputs=[{inputs}], group="install")',
315
381
  'copy("pyproject.toml", "pyproject.toml")'
316
- if not self.install_requires_all_files
382
+ if not self.config.install_requires_all_files
317
383
  else None,
318
384
  f'run("uv add {extra_deps}", group="install")' if extra_deps else None,
319
385
  ]
@@ -325,9 +391,11 @@ class PythonProvider:
325
391
  ]
326
392
  elif has_requirements or extra_deps:
327
393
  steps += [
328
- 'env(UV_PROJECT_ENVIRONMENT=local_venv["build"] if cross_platform else venv["build"])',
394
+ 'env(UV_PROJECT_ENVIRONMENT=local_venv.path if cross_platform else venv.path)',
329
395
  'run(f"uv init", inputs=[], outputs=["uv.lock"], group="install")',
330
- 'copy(".", ".", ignore=[".venv", ".git", "__pycache__"])' if self.install_requires_all_files else None,
396
+ 'copy(".", ".", ignore=[".venv", ".git", "__pycache__"])'
397
+ if self.config.install_requires_all_files
398
+ else None,
331
399
  ]
332
400
  if has_requirements:
333
401
  steps += [
@@ -345,15 +413,17 @@ class PythonProvider:
345
413
  ]
346
414
 
347
415
  steps += [
348
- 'path((local_venv["build"] if cross_platform else venv["build"]) + "/bin")',
349
- 'copy(".", ".", ignore=[".venv", ".git", "__pycache__"])' if not self.install_requires_all_files else None,
416
+ 'path((local_venv.path if cross_platform else venv.path) + "/bin")',
417
+ 'copy(".", ".", ignore=[".venv", ".git", "__pycache__"])'
418
+ if not self.config.install_requires_all_files
419
+ else None,
350
420
  ]
351
- if self.framework == PythonFramework.MCP:
421
+ if self.config.framework == PythonFramework.MCP:
352
422
  steps += [
353
- 'run("mkdir -p {}/bin".format(venv["build"])) if cross_platform else None',
354
- 'run("cp {}/bin/mcp {}/bin/mcp".format(local_venv["build"], venv["build"])) if cross_platform else None',
423
+ 'run("mkdir -p {}/bin".format(venv.path)) if cross_platform else None',
424
+ 'run("cp {}/bin/mcp {}/bin/mcp".format(local_venv.path, venv.path)) if cross_platform else None',
355
425
  ]
356
- if self.framework == PythonFramework.Django:
426
+ if self.config.framework == PythonFramework.Django:
357
427
  steps += [
358
428
  'run("python manage.py collectstatic --noinput", group="build")',
359
429
  ]
@@ -364,9 +434,9 @@ class PythonProvider:
364
434
  return []
365
435
  return [
366
436
  'run("echo \\"Precompiling Python code...\\"") if precompile_python else None',
367
- 'run(f"python -m compileall -o 2 {python_serve_site_packages_path}") if precompile_python else None',
437
+ 'run(f"python -m compileall -o 2 {python_serve_site_packages_path} || true") if precompile_python else None',
368
438
  'run("echo \\"Precompiling package code...\\"") if precompile_python else None',
369
- 'run(f"python -m compileall -o 2 {app_serve_path}") if precompile_python else None',
439
+ 'run(f"python -m compileall -o 2 {app_serve_path} || true") if precompile_python else None',
370
440
  ]
371
441
 
372
442
  @classmethod
@@ -380,91 +450,74 @@ class PythonProvider:
380
450
  if _exists(root_path, f"src/{path}"):
381
451
  return f"src/{path}"
382
452
  for path in paths_to_try:
383
- try:
384
- found_path = next(root_path.glob(f"**/{path}"))
385
- except StopIteration:
386
- found_path = None
453
+ found_path = next(root_path.glob(f"*/{path}"), None)
454
+ if not found_path:
455
+ found_path = next(root_path.glob(f"*/*/{path}"), None)
387
456
  if found_path:
388
457
  return str(found_path.relative_to(root_path))
389
458
  return None
390
459
 
391
- @cached_property
392
- def main_file(self) -> Optional[str]:
393
- return self.detect_main_file(self.path)
394
-
395
460
  def commands(self) -> Dict[str, str]:
396
- commands = self.base_commands()
397
- if self.custom_commands.start:
398
- commands["start"] = json.dumps(self.custom_commands.start)
399
- return commands
461
+ return self.base_commands()
400
462
 
401
463
  def base_commands(self) -> Dict[str, str]:
402
464
  if self.only_build:
403
465
  return {}
404
- if self.framework == PythonFramework.Django:
405
- start_cmd = None
406
- if self.server == PythonServer.Daphne and self.asgi_application:
407
- asgi_application = format_app_import(self.asgi_application)
408
- start_cmd = (
409
- f'f"python -m daphne {asgi_application} --bind 0.0.0.0 --port {{PORT}}"'
410
- )
411
- elif self.server == PythonServer.Uvicorn:
412
- if self.asgi_application:
413
- asgi_application = format_app_import(self.asgi_application)
414
- start_cmd = f'f"python -m uvicorn {asgi_application} --host 0.0.0.0 --port {{PORT}}"'
415
- elif self.wsgi_application:
416
- wsgi_application = format_app_import(self.wsgi_application)
417
- start_cmd = f'f"python -m uvicorn {wsgi_application} --interface=wsgi --host 0.0.0.0 --port {{PORT}}"'
418
- # elif self.server == PythonServer.Gunicorn:
419
- # start_cmd = f'"fpython -m gunicorn {self.wsgi_application} --bind 0.0.0.0 --port {{PORT}}"'
420
- if not start_cmd:
421
- # We run the default runserver command if no server is specified
422
- start_cmd = 'f"python manage.py runserver 0.0.0.0:{PORT}"'
423
- migrate_cmd = '"python manage.py migrate"'
424
- return {"start": start_cmd, "after_deploy": migrate_cmd}
425
-
426
- main_file = self.main_file
427
-
428
- if not main_file:
429
- start_cmd = '"python -c \'print(\\"No start command detected, please provide a start command manually\\")\'"'
430
- return {"start": start_cmd}
431
-
432
- if self.framework == PythonFramework.FastAPI:
433
- python_path = file_to_python_path(main_file)
434
- path = f"{python_path}:app"
435
- if self.server == PythonServer.Uvicorn:
436
- start_cmd = f'f"python -m uvicorn {path} --host 0.0.0.0 --port {{PORT}}"'
437
- elif self.server == PythonServer.Hypercorn:
438
- start_cmd = f'f"python -m hypercorn {path} --bind 0.0.0.0:{{PORT}}"'
439
- else:
440
- start_cmd = '"python -c \'print(\\"No start command detected, please provide a start command manually\\")\'"'
441
- return {"start": start_cmd}
442
-
443
- elif self.framework == PythonFramework.Streamlit:
444
- start_cmd = f'f"python -m streamlit run {main_file} --server.port {{PORT}} --server.address 0.0.0.0 --server.headless true"'
445
-
446
- elif self.framework == PythonFramework.Flask:
447
- python_path = file_to_python_path(main_file)
448
- path = f"{python_path}:app"
449
- # start_cmd = f'f"python -m flask --app {path} run --debug --host 0.0.0.0 --port {{PORT}}"'
450
- start_cmd = f'f"python -m uvicorn {path} --interface=wsgi --host 0.0.0.0 --port {{PORT}}"'
451
466
 
452
- elif self.framework == PythonFramework.MCP:
467
+ start_cmd = None
468
+ if self.config.server == PythonServer.Daphne:
469
+ assert self.config.asgi_application, (
470
+ "No ASGI application found for Daphne"
471
+ )
472
+ start_cmd = f'f"daphne {self.config.asgi_application} --bind 0.0.0.0 --port {{PORT}}"'
473
+ # elif self.config.server == PythonServer.Gunicorn:
474
+ # assert self.config.wsgi_application, "No WSGI application found"
475
+ # start_cmd = f'f"gunicorn {self.config.wsgi_application} --bind 0.0.0.0 --port {{PORT}}"'
476
+ elif self.config.server == PythonServer.Uvicorn:
477
+ if not self.config.main_file:
478
+ assert (
479
+ self.config.asgi_application or self.config.wsgi_application
480
+ ), (
481
+ "No ASGI or WSGI application found for Uvicorn and no main file found"
482
+ )
483
+ if self.config.asgi_application:
484
+ start_cmd = f'f"uvicorn {self.config.asgi_application} --host 0.0.0.0 --port {{PORT}}"'
485
+ elif self.config.wsgi_application:
486
+ start_cmd = f'f"uvicorn {self.config.wsgi_application} --interface=wsgi --host 0.0.0.0 --port {{PORT}}"'
487
+ elif self.config.server == PythonServer.Hypercorn:
488
+ assert self.config.asgi_application, (
489
+ "No ASGI application found for Hypercorn"
490
+ )
491
+ start_cmd = (
492
+ f'f"hypercorn {self.config.asgi_application} --bind 0.0.0.0:{{PORT}}"'
493
+ )
494
+ elif self.config.framework == PythonFramework.Streamlit:
495
+ assert self.config.main_file, "No main file found for Streamlit"
496
+ main_file = self.config.main_file
497
+ start_cmd = f'f"streamlit run {main_file} --server.port {{PORT}} --server.address 0.0.0.0 --server.headless true"'
498
+ elif self.config.framework == PythonFramework.MCP:
499
+ main_file = self.config.main_file
500
+ assert main_file, "No main file found for MCP"
453
501
  contents = (self.path / main_file).read_text()
454
502
  if 'if __name__ == "__main__"' in contents or "mcp.run" in contents:
455
503
  start_cmd = f'"python {main_file}"'
456
504
  else:
457
- start_cmd = f'"python {{}}/bin/mcp run {main_file} --transport=streamable-http".format(venv["serve"])'
505
+ start_cmd = f'"python {{}}/bin/mcp run {main_file} --transport=streamable-http".format(venv.serve_path)'
458
506
 
459
- elif self.framework == PythonFramework.FastHTML:
460
- python_path = file_to_python_path(main_file)
461
- path = f"{python_path}:app"
462
- start_cmd = f'f"python -m uvicorn {path} --host 0.0.0.0 --port {{PORT}}"'
507
+ if not start_cmd:
508
+ if self.config.main_file:
509
+ start_cmd = f'"python {self.config.main_file}"'
463
510
 
464
- else:
465
- start_cmd = f'"python {main_file}"'
511
+ if self.config.framework == PythonFramework.Django:
512
+ if not start_cmd:
513
+ start_cmd = 'f"python manage.py runserver 0.0.0.0:{PORT}"'
514
+ migrate_cmd = '"python manage.py migrate"'
515
+ return {"start": start_cmd, "after_deploy": migrate_cmd}
466
516
 
467
- return {"start": start_cmd}
517
+ if start_cmd:
518
+ return {"start": start_cmd}
519
+ else:
520
+ return {}
468
521
 
469
522
  def mounts(self) -> list[MountSpec]:
470
523
  if self.only_build:
@@ -486,24 +539,24 @@ class PythonProvider:
486
539
  return {}
487
540
  # For Django projects, generate an empty env dict to surface the field
488
541
  # in the Shipit file. Other Python projects omit it by default.
489
- python_path = "f\"{app_serve_path}:{python_serve_site_packages_path}\""
490
- main_file = self.main_file
542
+ python_path = 'f"{app_serve_path}:{python_serve_site_packages_path}"'
543
+ main_file = self.config.main_file
491
544
  if main_file and main_file.startswith("src/"):
492
- python_path = "f\"{app_serve_path}:{app_serve_path}/src:{python_serve_site_packages_path}\""
545
+ python_path = 'f"{app_serve_path}:{app_serve_path}/src:{python_serve_site_packages_path}"'
493
546
  else:
494
- python_path = "f\"{app_serve_path}:{python_serve_site_packages_path}\""
495
- env_vars = {"PYTHONPATH": python_path, "HOME": 'app["serve"]'}
496
- if self.framework == PythonFramework.Streamlit:
547
+ python_path = 'f"{app_serve_path}:{python_serve_site_packages_path}"'
548
+ env_vars = {"PYTHONPATH": python_path, "HOME": 'app.serve_path'}
549
+ if self.config.framework == PythonFramework.Streamlit:
497
550
  env_vars["STREAMLIT_SERVER_HEADLESS"] = '"true"'
498
- elif self.framework == PythonFramework.MCP:
551
+ elif self.config.framework == PythonFramework.MCP:
499
552
  env_vars["FASTMCP_HOST"] = '"0.0.0.0"'
500
- env_vars["FASTMCP_PORT"] = 'PORT'
553
+ env_vars["FASTMCP_PORT"] = "PORT"
501
554
  return env_vars
502
-
555
+
503
556
  def services(self) -> list[ServiceSpec]:
504
- if self.database == DatabaseType.MySQL:
557
+ if self.config.database == DatabaseType.MySQL:
505
558
  return [ServiceSpec(name="database", provider="mysql")]
506
- elif self.database == DatabaseType.PostgreSQL:
559
+ elif self.config.database == DatabaseType.PostgreSQL:
507
560
  return [ServiceSpec(name="database", provider="postgres")]
508
561
  return []
509
562
 
@@ -513,5 +566,8 @@ def format_app_import(asgi_application: str) -> str:
513
566
  return re.sub(r"\.([^.]+)$", r":\1", asgi_application)
514
567
 
515
568
 
516
- def file_to_python_path(path: str) -> str:
517
- return path.rstrip(".py").replace("/", ".").replace("\\", ".")
569
+ def file_to_python_path(path: Optional[str]) -> Optional[str]:
570
+ if not path:
571
+ return None
572
+ file = path.rstrip(".py").replace("/", ".").replace("\\", ".")
573
+ return f"{file}:app"