pywire-cli 0.2.0__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.
pywire_cli/main.py ADDED
@@ -0,0 +1,894 @@
1
+ """Main CLI entry point."""
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ try:
9
+ import rich.panel
10
+ import rich_click as click
11
+ from rich.console import Console
12
+ except ImportError:
13
+ print(
14
+ "Error: pywire CLI requires additional dependencies.\n"
15
+ "Install them with: uv add pywire[cli] (or: pip install pywire[cli])",
16
+ file=sys.stderr,
17
+ )
18
+ sys.exit(1)
19
+
20
+ from pywire import __version__
21
+ from pywire_cli.config import config_command
22
+
23
+ console = Console()
24
+
25
+ # Astro-like styling configuration (Cyan Theme)
26
+ click.rich_click.USE_RICH_MARKUP = True
27
+ click.rich_click.STYLE_HELPTEXT_FIRST = True
28
+ click.rich_click.STYLE_COMMANDS_TABLE_SHOW_LINES = False
29
+ click.rich_click.STYLE_COMMANDS_TABLE_PAD_EDGE = False
30
+ click.rich_click.STYLE_COMMANDS_TABLE_BOX = None
31
+ click.rich_click.STYLE_COMMANDS_TABLE_EXPAND = False
32
+ click.rich_click.STYLE_OPTIONS_TABLE_EXPAND = False
33
+ click.rich_click.STYLE_COMMANDS_TABLE_HEADER = "bold magenta"
34
+ click.rich_click.STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO = None
35
+ click.rich_click.SHOW_ARGUMENTS = True
36
+ click.rich_click.GROUP_ARGUMENTS_OPTIONS = True
37
+ click.rich_click.STYLE_ERRORS_SUGGESTION = "magenta italic"
38
+ click.rich_click.ERRORS_SUGGESTION = "Try running 'pywire --help' for more information."
39
+ click.rich_click.ERRORS_EPILOGUE = "To find out more, visit [link=https://github.com/pywire/pywire]https://github.com/pywire/pywire[/link]"
40
+ click.rich_click.STYLE_OPTIONS_TABLE_BOX = None
41
+ click.rich_click.STYLE_COMMANDS_PANEL_BOX = None
42
+ click.rich_click.STYLE_OPTIONS_PANEL_BOX = None
43
+
44
+ # Cyan theme
45
+ click.rich_click.STYLE_HEADER_TEXT = "bold cyan"
46
+ click.rich_click.STYLE_OPTION = "cyan"
47
+ click.rich_click.STYLE_SWITCH = "cyan"
48
+ click.rich_click.STYLE_METAVAR = "dim white"
49
+ click.rich_click.STYLE_USAGE_COMMAND = "cyan"
50
+ click.rich_click.STYLE_USAGE = "dim"
51
+
52
+ # Grouping options and commands
53
+ click.rich_click.OPTION_GROUPS = {
54
+ "pywire": [
55
+ {
56
+ "name": "Global Flags",
57
+ "options": ["--help", "--version"],
58
+ }
59
+ ]
60
+ }
61
+
62
+ click.rich_click.COMMAND_GROUPS = {
63
+ "pywire": [
64
+ {
65
+ "name": "Commands",
66
+ "commands": ["dev", "run", "build", "check", "deploy"],
67
+ },
68
+ {
69
+ "name": "Configuration",
70
+ "commands": ["config"],
71
+ },
72
+ ]
73
+ }
74
+
75
+
76
+ def _setup_import_paths(module_name: str) -> None:
77
+ """Configure sys.path so a dotted module string and its sibling imports resolve.
78
+
79
+ For ``src.main``, both the project root (so ``src`` is a package) and
80
+ ``src/`` itself (so ``from auth_middleware import …`` works inside main.py)
81
+ are prepended. Works for arbitrary nesting depth.
82
+ """
83
+ cwd = os.getcwd()
84
+ if cwd not in sys.path:
85
+ sys.path.insert(0, cwd)
86
+
87
+ # Add every intermediate directory so that relative-style imports within
88
+ # each layer work without requiring the full dotted prefix.
89
+ parts = module_name.split(".")
90
+ for depth in range(1, len(parts)):
91
+ subdir = os.path.join(cwd, *parts[:depth])
92
+ if os.path.isdir(subdir) and subdir not in sys.path:
93
+ sys.path.insert(0, subdir)
94
+
95
+
96
+ def import_app(app_str: str) -> Any:
97
+ """Import application from string (e.g. 'main:app' or 'src.main:app')."""
98
+ if ":" not in app_str:
99
+ raise click.BadParameter("App must be in format 'module:app'", param_hint="APP")
100
+
101
+ module_name, app_name = app_str.split(":", 1)
102
+
103
+ _setup_import_paths(module_name)
104
+
105
+ try:
106
+ import importlib
107
+
108
+ module = importlib.import_module(module_name)
109
+ except ImportError as e:
110
+ raise click.BadParameter(
111
+ f"Could not import module '{module_name}': {e}", param_hint="APP"
112
+ )
113
+
114
+ try:
115
+ app = getattr(module, app_name)
116
+ except AttributeError:
117
+ raise click.BadParameter(
118
+ f"Attribute '{app_name}' not found in module '{module_name}'",
119
+ param_hint="APP",
120
+ )
121
+
122
+ return app
123
+
124
+
125
+ def _discover_app_str() -> str:
126
+ """Try to discover the app string automatically."""
127
+ cwd = Path(os.getcwd())
128
+
129
+ # Priority: main.py, app.py, api.py
130
+ # Also check src/ directory
131
+ search_paths = [cwd, cwd / "src"]
132
+
133
+ for path in search_paths:
134
+ if not path.exists():
135
+ continue
136
+
137
+ for filename in ["main.py", "app.py", "api.py"]:
138
+ if (path / filename).exists():
139
+ # Check for common app instance names: app, api
140
+ module_name = filename[:-3]
141
+
142
+ # Construct module path (e.g. src.main)
143
+ if path.name == "src":
144
+ module_path = f"src.{module_name}"
145
+ else:
146
+ module_path = module_name
147
+
148
+ # Simple check: try to import and look for app
149
+ try:
150
+ _setup_import_paths(module_path)
151
+ import importlib
152
+
153
+ module = importlib.import_module(module_path)
154
+
155
+ if hasattr(module, "app"):
156
+ return f"{module_path}:app"
157
+ if hasattr(module, "api"):
158
+ return f"{module_path}:api"
159
+
160
+ except ImportError:
161
+ continue
162
+
163
+ raise click.UsageError(
164
+ "Could not auto-discover app. Please provide 'APP' argument (e.g. 'main:app')."
165
+ )
166
+
167
+
168
+ # Workaround: rich-click wraps tables in Panels which default to expand=True.
169
+ # We monkeypatch Panel to default expand=False to allow natural resizing.
170
+ original_panel_init = rich.panel.Panel.__init__
171
+
172
+
173
+ def panel_init(self, *args, **kwargs):
174
+ kwargs.setdefault("expand", False)
175
+ original_panel_init(self, *args, **kwargs)
176
+
177
+
178
+ rich.panel.Panel.__init__ = panel_init # type: ignore[method-assign] # ty: ignore[invalid-assignment]
179
+
180
+
181
+ def _find_available_port(host: str, port: int, max_attempts: int = 100) -> int:
182
+ """Find an available port starting from 'port'."""
183
+ import socket
184
+
185
+ # Try to determine if we should use IPv4 or IPv6
186
+ try:
187
+ addr_info = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
188
+ family = addr_info[0][0]
189
+ except Exception:
190
+ family = socket.AF_INET # Fallback
191
+
192
+ for p in range(port, port + max_attempts):
193
+ with socket.socket(family, socket.SOCK_STREAM) as s:
194
+ try:
195
+ s.bind((host, p))
196
+ return p
197
+ except OSError:
198
+ continue
199
+
200
+ raise click.UsageError(
201
+ f"Could not find an available port starting from {port} after {max_attempts} attempts."
202
+ )
203
+
204
+
205
+ @click.group(
206
+ help=f"""
207
+ [bold white on cyan] pywire [/] [bold cyan]v{__version__}[/] Build faster python web apps.
208
+
209
+ Run [bold cyan]pywire dev APP[/] to start development server.
210
+ Run [bold cyan]pywire run APP[/] to start production server.
211
+
212
+ [dim]APP should be a string in format 'module:instance', e.g. 'src.main:app' or 'main:app'
213
+ If not provided, pywire tries to discover it in main.py, app.py, etc.[/dim]
214
+ """
215
+ )
216
+ @click.version_option(__version__)
217
+ def cli() -> None:
218
+ # Run pywire's .env cascade before any subcommand so app-import-time
219
+ # code (provider constructors, LocalIdP, direct os.environ reads in
220
+ # main.py, etc.) sees the env vars populated.
221
+ from pywire.config import _ensure_loaded
222
+
223
+ _ensure_loaded()
224
+
225
+
226
+ cli.add_command(config_command)
227
+
228
+
229
+ @cli.command()
230
+ @click.argument("app", required=False)
231
+ @click.option("--host", default="127.0.0.1", help="Host to bind to")
232
+ @click.option(
233
+ "--port", default=None, type=int, help="Port to bind to (default: 3000 or config)"
234
+ )
235
+ @click.option("--ssl-keyfile", default=None, help="SSL key file")
236
+ @click.option("--ssl-certfile", default=None, help="SSL certificate file")
237
+ @click.option("--env-file", default=None, help="Environment configuration file")
238
+ @click.option("--tui/--no-tui", default=None, help="Enable/disable TUI dashboard")
239
+ def dev(
240
+ app: Optional[str],
241
+ host: str,
242
+ port: Optional[int],
243
+ ssl_keyfile: Optional[str],
244
+ ssl_certfile: Optional[str],
245
+ env_file: Optional[str],
246
+ tui: Optional[bool],
247
+ ) -> None:
248
+ """Start development server."""
249
+ import asyncio
250
+
251
+ from pywire_cli.config import get_setting
252
+ from pywire.runtime.dev_server import run_dev_server
253
+
254
+ # Resolve TUI setting: CLI flag > settings.toml > default (False)
255
+ if tui is None:
256
+ saved = get_setting("tui")
257
+ use_tui = saved if isinstance(saved, bool) else False
258
+ else:
259
+ use_tui = tui
260
+
261
+ # Resolve port: CLI flag > settings.toml > default (3000)
262
+ if port is None:
263
+ saved_port = get_setting("port")
264
+ port = int(saved_port) if saved_port is not None else 3000
265
+ assert port is not None
266
+
267
+ if not app:
268
+ app = _discover_app_str()
269
+
270
+ # .env already loaded in the cli group callback (see pywire.config
271
+ # cascade). --env-file is a forward-compat flag consumed by the TUI
272
+ # subprocess path below.
273
+ import_app(app)
274
+
275
+ # Find available port
276
+ original_port = port
277
+ port = _find_available_port(host, port)
278
+
279
+ if not use_tui:
280
+ asyncio.run(
281
+ run_dev_server(
282
+ app_str=app, # Pass string for reloadability hooks if needed
283
+ host=host,
284
+ port=port,
285
+ ssl_keyfile=ssl_keyfile,
286
+ ssl_certfile=ssl_certfile,
287
+ original_port=original_port,
288
+ )
289
+ )
290
+ else:
291
+ from pywire_cli.tui import start_tui
292
+
293
+ start_tui(
294
+ app_path=app,
295
+ host=host,
296
+ port=port,
297
+ ssl_keyfile=ssl_keyfile,
298
+ ssl_certfile=ssl_certfile,
299
+ env_file=env_file,
300
+ )
301
+
302
+
303
+ @cli.command()
304
+ @click.argument("app", required=False)
305
+ @click.option(
306
+ "--optimize",
307
+ is_flag=True,
308
+ help="Compile bytecode artifacts for faster import.",
309
+ )
310
+ @click.option(
311
+ "--out-dir",
312
+ default=".pywire/build",
313
+ help="Output directory for build artifacts.",
314
+ )
315
+ @click.option(
316
+ "--pages-dir",
317
+ default=None,
318
+ help="Override pages directory (default: app.pages_dir).",
319
+ )
320
+ @click.option(
321
+ "--platform",
322
+ type=click.Choice(["cloudflare"]),
323
+ default=None,
324
+ help="Generate platform-specific build output.",
325
+ )
326
+ def build(
327
+ app: Optional[str],
328
+ optimize: bool,
329
+ out_dir: str,
330
+ pages_dir: Optional[str],
331
+ platform: Optional[str],
332
+ ) -> None:
333
+ """Build the application for production."""
334
+ if not app:
335
+ app = _discover_app_str()
336
+
337
+ console.print(f"🔨 Building [cyan]{app}[/]...")
338
+
339
+ app_instance = import_app(app)
340
+
341
+ if pages_dir:
342
+ resolved_pages_dir = Path(pages_dir)
343
+ elif hasattr(app_instance, "pages_dir"):
344
+ resolved_pages_dir = Path(app_instance.pages_dir)
345
+ else:
346
+ resolved_pages_dir = Path("pages")
347
+
348
+ from pywire.compiler.build import build_project
349
+ from pywire_cli.check import collect_diagnostics, format_rich, summarize
350
+
351
+ pre_diags = collect_diagnostics(resolved_pages_dir)
352
+ pre_summary = summarize(pre_diags)
353
+ if pre_diags:
354
+ format_rich(pre_diags, console)
355
+ if pre_summary.errors:
356
+ console.print(
357
+ f"\n[bold red]Build blocked: "
358
+ f"{pre_summary.errors} analysis error(s).[/] "
359
+ "Fix them or run [cyan]pywire check[/] for details."
360
+ )
361
+ sys.exit(1)
362
+ console.print(
363
+ f"[yellow]Continuing with "
364
+ f"{pre_summary.warnings} warning(s), "
365
+ f"{pre_summary.infos} info(s)[/]"
366
+ )
367
+
368
+ # Resolve static_dir for asset fingerprinting
369
+ resolved_static_dir = None
370
+ if hasattr(app_instance, "static_dir") and app_instance.static_dir:
371
+ resolved_static_dir = Path(app_instance.static_dir)
372
+
373
+ summary = build_project(
374
+ optimize=optimize,
375
+ pages_dir=resolved_pages_dir,
376
+ out_dir=Path(out_dir),
377
+ static_dir=resolved_static_dir,
378
+ )
379
+
380
+ parts = [
381
+ f"pages={summary.pages}",
382
+ f"layouts={summary.layouts}",
383
+ f"components={summary.components}",
384
+ ]
385
+ if summary.static_assets > 0:
386
+ parts.append(f"static_assets={summary.static_assets}")
387
+ parts.append(f"out={summary.out_dir}")
388
+
389
+ console.print(f"✅ Build complete ({', '.join(parts)})")
390
+
391
+ if platform == "cloudflare":
392
+ import shutil
393
+
394
+ from pywire.compiler.build_artifacts import generate_cf_bundle
395
+
396
+ cf_bundle_dir = Path.cwd() / "_pywire_build"
397
+ routes_path = generate_cf_bundle(
398
+ build_dir=Path(out_dir),
399
+ cf_bundle_dir=cf_bundle_dir,
400
+ app_import=app,
401
+ )
402
+
403
+ # Copy static assets to .pywire/deploy/public/ for Cloudflare's
404
+ # native static assets binding (served from edge CDN, not the Worker).
405
+ # The wrangler.toml [assets] directive points to .pywire/deploy/public.
406
+ deploy_public = Path.cwd() / ".pywire" / "deploy" / "public"
407
+ if deploy_public.exists():
408
+ shutil.rmtree(deploy_public)
409
+
410
+ # PyWire framework JS — resolve from installed pywire package, not
411
+ # from this file's location (pywire-cli now lives in its own package).
412
+ import pywire
413
+
414
+ pywire_static_src = Path(pywire.__file__).parent / "static"
415
+ pywire_static_dest = deploy_public / "_pywire" / "static"
416
+ if pywire_static_src.exists():
417
+ pywire_static_dest.mkdir(parents=True, exist_ok=True)
418
+ for f in pywire_static_src.iterdir():
419
+ if f.is_file() and (f.suffix in (".js", ".css", ".map")):
420
+ shutil.copy2(f, pywire_static_dest / f.name)
421
+
422
+ # User static files — respect the app's configured static_url_path
423
+ user_static = app_instance.static_dir if app_instance else None
424
+ static_url_path = getattr(app_instance, "static_url_path", "/static")
425
+ # Strip leading slash to make it a relative path for the deploy dir
426
+ static_subdir = static_url_path.lstrip("/")
427
+ if user_static and Path(user_static).exists() and Path(user_static).is_dir():
428
+ user_static_dest = deploy_public / static_subdir
429
+ shutil.copytree(user_static, user_static_dest)
430
+
431
+ # Regenerate pywire_do.py (contains app import path)
432
+ from pywire_cli.deploy import generate_cf_durable_object
433
+
434
+ do_content = generate_cf_durable_object(Path.cwd(), app or "src.main:app")
435
+ (Path.cwd() / "pywire_do.py").write_text(do_content)
436
+
437
+ console.print(
438
+ f"✅ Generated [cyan]_pywire_build/[/], [cyan]{routes_path.name}[/], "
439
+ f"and [cyan]pywire_do.py[/] for Cloudflare Workers"
440
+ )
441
+ console.print(
442
+ "✅ Static assets → [cyan].pywire/deploy/public/[/] "
443
+ "(served by Cloudflare edge CDN)"
444
+ )
445
+
446
+
447
+ @cli.command()
448
+ @click.argument("app", required=False)
449
+ @click.option(
450
+ "--pages-dir",
451
+ default=None,
452
+ help="Override pages directory (default: app.pages_dir or ./pages).",
453
+ )
454
+ @click.option(
455
+ "--rule",
456
+ "rules",
457
+ multiple=True,
458
+ help="Only run specific rules (e.g. --rule PW001 --rule PW003). Default: all.",
459
+ )
460
+ @click.option(
461
+ "--plain",
462
+ is_flag=True,
463
+ help="Emit ruff-style plain text (file:line:col: severity [code] message).",
464
+ )
465
+ @click.option(
466
+ "--strict",
467
+ is_flag=True,
468
+ help="Exit 1 on any warning or info (CI mode). Default exits 1 only on errors.",
469
+ )
470
+ @click.option(
471
+ "--fix",
472
+ is_flag=True,
473
+ help="(Stub — not implemented yet) Attempt to apply suggested fixes.",
474
+ )
475
+ def check(
476
+ app: Optional[str],
477
+ pages_dir: Optional[str],
478
+ rules: tuple[str, ...],
479
+ plain: bool,
480
+ strict: bool,
481
+ fix: bool,
482
+ ) -> None:
483
+ """Run static analysis on a PyWire project."""
484
+ from pywire_cli.check import (
485
+ collect_diagnostics,
486
+ format_plain,
487
+ format_rich,
488
+ summarize,
489
+ )
490
+
491
+ if fix:
492
+ console.print(
493
+ "[yellow]--fix is not implemented yet.[/] "
494
+ "See pywire_parser/analysis/ROADMAP.md for the fix protocol plan."
495
+ )
496
+
497
+ if pages_dir:
498
+ resolved = Path(pages_dir)
499
+ else:
500
+ if not app:
501
+ try:
502
+ app = _discover_app_str()
503
+ except Exception:
504
+ app = None
505
+ if app:
506
+ try:
507
+ inst = import_app(app)
508
+ resolved = (
509
+ Path(inst.pages_dir)
510
+ if hasattr(inst, "pages_dir")
511
+ else Path("pages")
512
+ )
513
+ except Exception:
514
+ resolved = Path("pages")
515
+ else:
516
+ resolved = Path("pages")
517
+
518
+ rule_codes = list(rules) if rules else None
519
+ diags = collect_diagnostics(resolved, rule_codes=rule_codes)
520
+
521
+ if plain:
522
+ if diags:
523
+ click.echo(format_plain(diags))
524
+ else:
525
+ if not diags:
526
+ console.print("[green]✓ No issues found.[/]")
527
+ else:
528
+ format_rich(diags, console)
529
+
530
+ summary = summarize(diags, strict=strict)
531
+ if not plain and summary.total > 0:
532
+ console.print(
533
+ f"\n[dim]{summary.errors} error(s), "
534
+ f"{summary.warnings} warning(s), "
535
+ f"{summary.infos} info(s)[/]"
536
+ )
537
+
538
+ if summary.exit_code != 0:
539
+ sys.exit(summary.exit_code)
540
+
541
+
542
+ @cli.command()
543
+ @click.argument("app", required=False)
544
+ @click.option("--host", default="0.0.0.0", help="Host to bind to")
545
+ @click.option("--port", default=8000, type=int, help="Port to bind to")
546
+ @click.option("--workers", default=None, type=int, help="Number of worker processes")
547
+ @click.option("--no-access-log", is_flag=True, help="Disable access logging")
548
+ def run(
549
+ app: Optional[str],
550
+ host: str,
551
+ port: int,
552
+ workers: Optional[int],
553
+ no_access_log: bool,
554
+ ) -> None:
555
+ """Run production server using Uvicorn."""
556
+ import multiprocessing
557
+
558
+ import uvicorn
559
+
560
+ if not app:
561
+ app = _discover_app_str()
562
+ click.echo(f"🔍 Auto-discovered app: {app}")
563
+
564
+ if workers is None:
565
+ workers = (multiprocessing.cpu_count() * 2) + 1
566
+
567
+ console.print(f"🚀 Starting [bold]production[/] server for [cyan]{app}[/]")
568
+ console.print(
569
+ f"🌍 Listening on [link=http://{host}:{port}]http://{host}:{port}[/link]"
570
+ )
571
+ console.print(f"👷 Workers: {workers}")
572
+
573
+ # Locate the app object to verify, but pass string to uvicorn
574
+ import_app(app)
575
+
576
+ uvicorn.run(
577
+ app,
578
+ host=host,
579
+ port=port,
580
+ workers=workers,
581
+ access_log=not no_access_log,
582
+ factory=False,
583
+ )
584
+
585
+
586
+ def _print_skip_hint(
587
+ filename: str,
588
+ platform: str,
589
+ workers: int,
590
+ redis: bool,
591
+ project_name: str,
592
+ ) -> None:
593
+ """Print manual instructions when a file overwrite is declined."""
594
+ if filename == "Dockerfile":
595
+ console.print(
596
+ f" [dim]To apply [cyan]--workers {workers}[/], update your Dockerfile CMD:[/]\n"
597
+ f' [dim] CMD ["uv", "run", "pywire", "run", "--host", "0.0.0.0",'
598
+ f' "--port", "8000", "--workers", "{workers}"][/]'
599
+ )
600
+ elif filename == "render.yaml" and redis:
601
+ console.print(
602
+ " [dim]To add Redis manually, add to [cyan]render.yaml[/]:[/]\n"
603
+ " [dim] - type: keyvalue[/]\n"
604
+ f" [dim] name: {project_name}-kv[/]\n"
605
+ " [dim] plan: starter[/]\n"
606
+ " [dim] ipAllowList: [][/]\n"
607
+ " [dim]And bind REDIS_URL in your web service envVars.[/]"
608
+ )
609
+
610
+
611
+ @cli.command()
612
+ @click.argument("app", required=False)
613
+ @click.option(
614
+ "--platform",
615
+ type=click.Choice(["render", "docker", "fly", "railway", "cloudflare"]),
616
+ default="docker",
617
+ help="Deployment platform",
618
+ )
619
+ @click.option(
620
+ "--out-dir",
621
+ default=".",
622
+ type=click.Path(),
623
+ help="Output directory for deploy configs",
624
+ )
625
+ @click.option(
626
+ "--workers",
627
+ default=1,
628
+ type=int,
629
+ help="Number of worker processes (default: 1)",
630
+ )
631
+ @click.option(
632
+ "--redis",
633
+ is_flag=True,
634
+ help="Include Redis/Valkey KV store in deployment config",
635
+ )
636
+ def deploy(
637
+ app: Optional[str],
638
+ platform: str,
639
+ out_dir: str,
640
+ workers: int,
641
+ redis: bool,
642
+ ) -> None:
643
+ """Generate deployment configuration for your PyWire app."""
644
+ from pywire_cli.deploy import (
645
+ generate_dockerfile,
646
+ generate_fly_toml,
647
+ generate_railway_json,
648
+ generate_render_yaml,
649
+ validate_deploy_config,
650
+ )
651
+
652
+ project_root = Path(os.getcwd())
653
+ out_path = Path(out_dir)
654
+
655
+ # Auto-discover and verify app
656
+ if not app:
657
+ app = _discover_app_str()
658
+ console.print(f"📦 Preparing deploy config for [cyan]{app}[/]...")
659
+
660
+ # Pre-compile
661
+ app_instance = import_app(app)
662
+
663
+ pages_dir = Path(getattr(app_instance, "pages_dir", "pages"))
664
+
665
+ from pywire.compiler.build import build_project
666
+
667
+ build_project(pages_dir=pages_dir, out_dir=Path(".pywire/build"))
668
+ console.print("✅ Build complete")
669
+
670
+ # Validate
671
+ issues = validate_deploy_config(platform, project_root)
672
+ if issues:
673
+ for issue in issues:
674
+ console.print(f"⚠️ {issue}")
675
+
676
+ # Derive project name from directory
677
+ project_name = project_root.name
678
+
679
+ # Cloudflare Workers requires a paid plan
680
+ if platform == "cloudflare":
681
+ console.print(
682
+ "\n[bold yellow]Note:[/] Cloudflare Python Workers requires a "
683
+ "[bold]Workers Paid plan[/] ($5/month).\n"
684
+ " The free plan's size and startup limits are incompatible with "
685
+ "Python frameworks.\n"
686
+ " [link=https://dash.cloudflare.com/workers/plans]"
687
+ "https://dash.cloudflare.com/workers/plans[/link]\n"
688
+ )
689
+
690
+ # Cloudflare uses Durable Objects — workers/redis flags don't apply
691
+ if platform == "cloudflare" and (workers > 1 or redis):
692
+ console.print(
693
+ "[bold red]Error:[/] [cyan]--workers[/] and [cyan]--redis[/] are not applicable "
694
+ "to Cloudflare Workers.\n"
695
+ " Cloudflare uses Durable Objects for session state — no Redis or worker "
696
+ "processes needed."
697
+ )
698
+ raise SystemExit(1)
699
+
700
+ # Warn about workers vs redis (not applicable to Cloudflare)
701
+ if platform != "cloudflare":
702
+ if workers > 1 and not redis:
703
+ console.print(
704
+ "\n[bold yellow]⚠️ Warning:[/] Running multiple workers without Redis "
705
+ "will break session state.\n"
706
+ " Add [cyan]--redis[/] or set [cyan]REDIS_URL[/] at runtime.\n"
707
+ )
708
+
709
+ if redis:
710
+ console.print(
711
+ "\n[bold yellow]⚠️ Note:[/] Adding a Redis/Valkey store will increase "
712
+ "resource usage and may\n"
713
+ " incur additional costs depending on your hosting provider.\n"
714
+ )
715
+
716
+ # Generate config files
717
+ files_to_write: list[tuple[str, str]] = []
718
+
719
+ if platform == "docker":
720
+ files_to_write.append(
721
+ ("Dockerfile", generate_dockerfile(project_root, workers=workers))
722
+ )
723
+ elif platform == "render":
724
+ files_to_write.append(
725
+ (
726
+ "render.yaml",
727
+ generate_render_yaml(project_root, project_name, redis=redis),
728
+ )
729
+ )
730
+ # Render uses Docker — always include Dockerfile so workers changes are picked up
731
+ files_to_write.append(
732
+ ("Dockerfile", generate_dockerfile(project_root, workers=workers))
733
+ )
734
+ elif platform == "fly":
735
+ files_to_write.append(
736
+ ("fly.toml", generate_fly_toml(project_root, project_name))
737
+ )
738
+ # Fly.io uses Docker — always include Dockerfile so workers changes are picked up
739
+ files_to_write.append(
740
+ ("Dockerfile", generate_dockerfile(project_root, workers=workers))
741
+ )
742
+ elif platform == "railway":
743
+ files_to_write.append(("railway.json", generate_railway_json(project_root)))
744
+ # Always include Dockerfile so workers changes are picked up
745
+ files_to_write.append(
746
+ ("Dockerfile", generate_dockerfile(project_root, workers=workers))
747
+ )
748
+ elif platform == "cloudflare":
749
+ from pywire_cli.deploy import (
750
+ generate_wrangler_toml,
751
+ generate_cf_entry,
752
+ generate_cf_durable_object,
753
+ )
754
+
755
+ files_to_write.append(
756
+ ("wrangler.toml", generate_wrangler_toml(project_root, project_name))
757
+ )
758
+ files_to_write.append(
759
+ ("entry.py", generate_cf_entry(project_root, app_string=app))
760
+ )
761
+ files_to_write.append(
762
+ ("pywire_do.py", generate_cf_durable_object(project_root, app_string=app))
763
+ )
764
+ # Exclude local .venv from CF bundle to avoid duplicate packages
765
+ files_to_write.append(
766
+ (
767
+ ".wranglerignore",
768
+ ".venv/\n.git/\n__pycache__/\n.pywire/build/\n",
769
+ )
770
+ )
771
+ else:
772
+ raise click.UsageError(f"Unknown platform: {platform}")
773
+
774
+ for filename, content in files_to_write:
775
+ target = out_path / filename
776
+ if target.exists():
777
+ if not click.confirm(f"'{target}' already exists. Overwrite?"):
778
+ console.print(f"Skipped [cyan]{target}[/]")
779
+ _print_skip_hint(filename, platform, workers, redis, project_name)
780
+ continue
781
+ target.parent.mkdir(parents=True, exist_ok=True)
782
+ target.write_text(content)
783
+ console.print(f"✅ Generated [cyan]{target}[/]")
784
+
785
+ # Next steps guidance
786
+ redis_hint = (
787
+ "\n[bold]Scaling with Redis/Valkey:[/]\n"
788
+ f" The Dockerfile runs with [cyan]--workers {workers}[/].\n"
789
+ " To scale, install [cyan]pywire[redis][/] and set [cyan]REDIS_URL[/] —\n"
790
+ " PyWire auto-detects it for shared session state (no code changes needed)."
791
+ )
792
+
793
+ if platform == "docker":
794
+ console.print(
795
+ "\n[bold]Next steps:[/]\n"
796
+ f" 1. [cyan]docker build -t {project_name} .[/]\n"
797
+ f" 2. [cyan]docker run -p 8000:8000 {project_name}[/]"
798
+ )
799
+ if not redis:
800
+ console.print(
801
+ redis_hint + "\n"
802
+ f" [cyan]docker run -p 8000:8000 -e REDIS_URL=redis://your-redis:6379 {project_name}[/]"
803
+ )
804
+ else:
805
+ console.print(
806
+ "\n Redis is configured. Run with [cyan]REDIS_URL[/]:\n"
807
+ f" [cyan]docker run -p 8000:8000 -e REDIS_URL=redis://your-redis:6379 {project_name}[/]"
808
+ )
809
+ elif platform == "render":
810
+ console.print(
811
+ "\n[bold]Next steps:[/]\n"
812
+ " 1. Push your code to a Git repository\n"
813
+ " 2. Go to [link=https://dashboard.render.com]dashboard.render.com[/link] "
814
+ "→ [bold]New → Blueprint[/] and connect your repo\n"
815
+ " 3. Render reads [cyan]render.yaml[/] automatically and provisions the service"
816
+ )
817
+ if redis:
818
+ console.print(
819
+ "\n Redis KV store is included in [cyan]render.yaml[/]. Render will provision\n"
820
+ " it and inject [cyan]REDIS_URL[/] automatically."
821
+ )
822
+ else:
823
+ console.print(
824
+ redis_hint + "\n"
825
+ " Use [cyan]pywire deploy --platform render --redis[/] to generate a\n"
826
+ " [cyan]render.yaml[/] with a KV store pre-configured."
827
+ )
828
+ elif platform == "fly":
829
+ console.print(
830
+ "\n[bold]Next steps:[/]\n"
831
+ " 1. Install the Fly CLI: [cyan]curl -L https://fly.io/install.sh | sh[/]\n"
832
+ " 2. Run [cyan]fly launch --no-deploy[/] to import [cyan]fly.toml[/]\n"
833
+ " 3. Deploy with [cyan]fly deploy[/]\n"
834
+ "\n[bold]Scaling:[/]\n"
835
+ f" The Dockerfile runs with [cyan]--workers {workers}[/].\n"
836
+ " To scale to multiple machines ([cyan]fly scale count N[/]):\n"
837
+ " • [bold]Option A — Fly sticky sessions:[/] Route sessions to the same machine\n"
838
+ " using [cyan]fly-replay[/]. Simple but breaks for VPNs/corporate proxies.\n"
839
+ " • [bold]Option B — Redis/Valkey (recommended):[/] Add Upstash Redis via\n"
840
+ " [cyan]fly redis create[/], set [cyan]REDIS_URL[/], and install\n"
841
+ " [cyan]pywire[redis][/]. PyWire auto-detects it — no code changes needed."
842
+ )
843
+ elif platform == "railway":
844
+ console.print(
845
+ "\n[bold]Next steps:[/]\n"
846
+ " 1. Install the Railway CLI: [cyan]npm i -g @railway/cli[/]\n"
847
+ " 2. Run [cyan]railway login[/] and [cyan]railway init[/]\n"
848
+ " 3. Run [cyan]railway link[/] to connect to your Railway project\n"
849
+ " 4. Deploy with [cyan]railway up[/]\n"
850
+ "\n Railway auto-detects the Dockerfile and builds your app."
851
+ )
852
+ if redis or workers > 1:
853
+ console.print(
854
+ redis_hint + "\n"
855
+ " [bold]Note:[/] Redis cannot be provisioned via [cyan]railway.json[/].\n"
856
+ " Add it with [cyan]railway add[/] (select Redis/Valkey) or via the\n"
857
+ " Railway dashboard. Railway injects [cyan]REDIS_URL[/] automatically.\n"
858
+ " Then install the Redis extra: [cyan]uv add pywire[redis][/]"
859
+ )
860
+ else:
861
+ console.print(
862
+ redis_hint + "\n"
863
+ " Add a Redis addon via [cyan]railway add[/] or the Railway dashboard.\n"
864
+ " Railway injects [cyan]REDIS_URL[/] automatically.\n"
865
+ " Then install the Redis extra: [cyan]uv add pywire[redis][/]"
866
+ )
867
+ elif platform == "cloudflare":
868
+ console.print(
869
+ "\n[bold]Local development:[/]\n"
870
+ " • [bold]Fast mode[/] (standard hot-reload, no build step needed):\n"
871
+ " [cyan]uv run pywire dev[/]\n"
872
+ " • [bold]Workers mode[/] (runs in local workerd — matches CF production):\n"
873
+ " [cyan]uv run pywire build --platform cloudflare[/]\n"
874
+ " [cyan]uv run pywrangler dev[/]\n"
875
+ "\n[bold]Deploy to Cloudflare:[/]\n"
876
+ " 1. Add workers-py if not present: [cyan]uv add --dev workers-py[/]\n"
877
+ " 2. Build: [cyan]uv run pywire build --platform cloudflare[/]\n"
878
+ " 3. Deploy: [cyan]uv run pywrangler deploy[/]\n"
879
+ "\n[bold]Generated files:[/]\n"
880
+ " • [cyan]wrangler.toml[/] — Cloudflare config with Durable Objects binding\n"
881
+ " • [cyan]entry.py[/] — Workers entry point (routes WS to Durable Objects)\n"
882
+ " • [cyan]pywire_do.py[/] — Durable Object for session + WebSocket handling\n"
883
+ "\n[bold]Architecture:[/]\n"
884
+ " Each session runs in a Durable Object with persistent storage and\n"
885
+ " WebSocket support. Real-time reactivity works out of the box.\n"
886
+ " No Redis or worker processes needed — Durable Objects handle state.\n"
887
+ "\n[bold]CI/CD:[/]\n"
888
+ " [cyan]uv sync && uv run pywire build --platform cloudflare "
889
+ "&& uv run pywrangler deploy[/]"
890
+ )
891
+
892
+
893
+ if __name__ == "__main__":
894
+ cli()