wynd 0.1.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.
wynd/__init__.py ADDED
File without changes
wynd/cli.py ADDED
@@ -0,0 +1,112 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from . import wrangler
7
+ from .host import warn_on_venv_mismatch
8
+ from .project import WyndError, load_project
9
+ from .shake import ShakePlan
10
+ from .stage import stage
11
+ from .sync import select_sdk, sync
12
+ from .targets import select_target
13
+ from .typegen import write_env
14
+
15
+ __all__ = ["main"]
16
+
17
+ # Reads the flags that choose the target and leaves everything else to Wrangler.
18
+ # --env-file and --profile are Wrangler's other global value flags, read only so
19
+ # their values aren't taken for the command.
20
+ _parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
21
+ _parser.add_argument("-c", "--config")
22
+ _parser.add_argument("-e", "--env")
23
+ _parser.add_argument("--cwd")
24
+ _parser.add_argument("--env-file", action="append")
25
+ _parser.add_argument("--profile")
26
+ # Wrangler flags that change the target after Wynd has installed for the
27
+ # config's. Wynd refuses them.
28
+ _parser.add_argument("--compatibility-date")
29
+ _parser.add_argument("--compatibility-flags", "--compatibility-flag", action="append")
30
+ _parser.add_argument("--latest", action="store_true")
31
+
32
+ # Wrangler 4.129.0's subcommands. Of these, only `versions upload` uploads code,
33
+ # and `preview` uploads code when it has none of them.
34
+ SUBCOMMANDS = {
35
+ "versions": {"view", "list", "upload", "deploy", "secret"},
36
+ "preview": {"delete", "settings", "secret", "base-config"},
37
+ }
38
+
39
+ # Takes only the config flag, so the remaining arguments keep their order.
40
+ _config_parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
41
+ _config_parser.add_argument("-c", "--config")
42
+
43
+
44
+ def main(argv: list[str] | None = None) -> int:
45
+ """Run a `wynd` command line. Commands Wynd doesn't own go to Wrangler."""
46
+ args = sys.argv[1:] if argv is None else argv
47
+ options, rest = _parser.parse_known_args(args)
48
+ flow = _flow(rest)
49
+ if flow == "passthrough":
50
+ return wrangler.run(args)
51
+
52
+ if flow in ("dev", "upload") and (
53
+ options.compatibility_date or options.compatibility_flags or options.latest
54
+ ):
55
+ print(
56
+ "wynd: error: --compatibility-date, --compatibility-flags and --latest "
57
+ "change the Python version after Wynd installs packages. Set "
58
+ "compatibility_date and compatibility_flags in the Wrangler config.",
59
+ file=sys.stderr,
60
+ )
61
+ return 1
62
+
63
+ try:
64
+ cwd = Path.cwd() / options.cwd if options.cwd else Path.cwd()
65
+ env = options.env or os.environ.get("CLOUDFLARE_ENV")
66
+ project = load_project(cwd, options.config, env)
67
+ if flow == "types":
68
+ write_env(project, check="--check" in rest)
69
+ return 0
70
+ target = select_target(project)
71
+ sync(project, target)
72
+ if flow != "sync" and select_sdk(project.dependencies) == "crosswind-workers":
73
+ write_env(project)
74
+ staged = stage(project) if flow == "upload" else None
75
+ except WyndError as error:
76
+ print(f"wynd: error: {error}", file=sys.stderr)
77
+ return 1
78
+
79
+ if flow == "sync":
80
+ return 0
81
+ if staged is not None:
82
+ return _upload(args, rest, *staged)
83
+
84
+ warn_on_venv_mismatch(project, target)
85
+ return wrangler.run(args)
86
+
87
+
88
+ def _flow(rest: list[str]) -> str:
89
+ """Sort a command line into the upload, dev, sync, types or passthrough flow."""
90
+ words = [arg for arg in rest if not arg.startswith("-")]
91
+ command = words[0] if words else ""
92
+ # A known name, not the second word, because a flag's value can come first.
93
+ known = SUBCOMMANDS.get(command, set())
94
+ subcommand = next((word for word in words[1:] if word in known), "")
95
+
96
+ if (
97
+ command == "deploy"
98
+ or (command == "versions" and subcommand == "upload")
99
+ or (command == "preview" and not subcommand)
100
+ ):
101
+ return "upload"
102
+ return command if command in ("dev", "sync", "types") else "passthrough"
103
+
104
+
105
+ def _upload(args: list[str], rest: list[str], path: Path, plan: ShakePlan) -> int:
106
+ """Run Wrangler on the staged config in place of the project's."""
107
+ if "--dry-run" in rest:
108
+ # Flushed, so it comes before Wrangler's output in a pipe.
109
+ print(f"wynd: {plan.size_report()}", flush=True)
110
+
111
+ _, rest = _config_parser.parse_known_args(args)
112
+ return wrangler.run([*rest, "--config", str(path)])
wynd/host.py ADDED
@@ -0,0 +1,28 @@
1
+ import re
2
+ import sys
3
+
4
+ from .project import Project
5
+ from .targets import Target
6
+
7
+ __all__ = ["warn_on_venv_mismatch"]
8
+
9
+
10
+ def warn_on_venv_mismatch(project: Project, target: Target) -> None:
11
+ """Warn when the editor's .venv runs another Python minor than the target."""
12
+ try:
13
+ pyvenv = (project.root / ".venv" / "pyvenv.cfg").read_text()
14
+ except FileNotFoundError:
15
+ return
16
+
17
+ match = re.search(r"^version_info\s*=\s*(\d+\.\d+)", pyvenv, re.MULTILINE)
18
+ if match is None or match.group(1) == target.version:
19
+ return
20
+
21
+ flags = ", ".join(f for f in project.compatibility_flags if "python_workers" in f)
22
+ print(
23
+ f"warning: .venv is Python {match.group(1)} but {project.config_path.name}"
24
+ f" targets {target.version} ({flags}, {project.compatibility_date}).\n"
25
+ "Editor types may differ from production."
26
+ f" To match: uv venv --python {target.version} && uv sync",
27
+ file=sys.stderr,
28
+ )
wynd/project.py ADDED
@@ -0,0 +1,141 @@
1
+ import tomllib
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import jsonc
7
+
8
+ __all__ = ["Project", "WyndError", "load_project"]
9
+
10
+ # Wrangler walks every parent for one name before it tries the next.
11
+ CONFIG_NAMES = ("wrangler.json", "wrangler.jsonc", "wrangler.toml")
12
+
13
+ # Top-level keys Wynd reads that an environment inherits when it leaves them out.
14
+ # Bindings, vars and the rest stay with the table that sets them.
15
+ INHERITED = (
16
+ "main",
17
+ "base_dir",
18
+ "compatibility_date",
19
+ "compatibility_flags",
20
+ "rules",
21
+ "assets",
22
+ "build",
23
+ "python_modules",
24
+ # Wrangler 4.129 inherits logfwdr, although it holds bindings.
25
+ "logfwdr",
26
+ )
27
+
28
+
29
+ class WyndError(Exception):
30
+ """An error Wynd reports to the user as one message."""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Project:
35
+ root: Path
36
+ config_path: Path
37
+ # The config file as parsed, every environment included.
38
+ config: dict[str, Any]
39
+ # The chosen environment's table over the top-level keys it inherits, or
40
+ # the top level without an environment.
41
+ settings: dict[str, Any]
42
+ main: str | None
43
+ base_dir: str | None
44
+ compatibility_date: str | None
45
+ compatibility_flags: tuple[str, ...]
46
+ dependencies: tuple[str, ...]
47
+ allow_build: bool
48
+ # Modules the shaker keeps although nothing imports them by a literal name.
49
+ keep: tuple[str, ...]
50
+
51
+ @property
52
+ def python_modules(self) -> Path:
53
+ # Wrangler collects python_modules/ beside the config file.
54
+ return self.config_path.parent / "python_modules"
55
+
56
+ @property
57
+ def source_dir(self) -> Path:
58
+ """The module root: base_dir, or the entrypoint's directory."""
59
+ if self.main is None:
60
+ raise WyndError(f"{self.config_path.name} sets no main")
61
+ config_dir = self.config_path.parent.resolve()
62
+ return (config_dir / (self.base_dir or Path(self.main).parent)).resolve()
63
+
64
+ @property
65
+ def wynd_dir(self) -> Path:
66
+ # Wynd's builds and state.
67
+ return self.root / ".wynd"
68
+
69
+
70
+ def load_project(cwd: Path, config: str | None, env: str | None) -> Project:
71
+ """Read the project the way Wrangler would for `-c config` and `-e env`."""
72
+ root = _find_up(cwd, "pyproject.toml")
73
+ if root is None:
74
+ raise WyndError(f"no pyproject.toml in {cwd} or any parent directory")
75
+
76
+ config_path = cwd / config if config else _find_config(cwd)
77
+ raw = _read(config_path)
78
+ settings = _environment(raw, env, config_path.name)
79
+
80
+ pyproject = _read(root / "pyproject.toml")
81
+ wynd = pyproject.get("tool", {}).get("wynd", {})
82
+ keep = wynd.get("keep", [])
83
+ if not isinstance(keep, list) or not all(isinstance(k, str) for k in keep):
84
+ raise WyndError("[tool.wynd] keep must be a list of module names")
85
+
86
+ date = settings.get("compatibility_date")
87
+ return Project(
88
+ root=root,
89
+ config_path=config_path,
90
+ config=raw,
91
+ settings=settings,
92
+ main=settings.get("main"),
93
+ base_dir=settings.get("base_dir"),
94
+ # TOML parses a bare date as datetime.date. Wrangler reads it as text.
95
+ compatibility_date=None if date is None else str(date),
96
+ compatibility_flags=tuple(settings.get("compatibility_flags", ())),
97
+ dependencies=tuple(pyproject.get("project", {}).get("dependencies", ())),
98
+ allow_build=bool(wynd.get("allow-build")),
99
+ keep=tuple(keep),
100
+ )
101
+
102
+
103
+ def _environment(config: dict[str, Any], env: str | None, name: str) -> dict[str, Any]:
104
+ """The chosen environment's table over the top-level keys it inherits."""
105
+ envs = config.get("env") or {}
106
+ # Without any env table, Wrangler warns and uses the top level, as Wynd does.
107
+ if env is None or not envs:
108
+ return config
109
+ if env not in envs:
110
+ raise WyndError(f"{name} has no environment named {env!r}")
111
+
112
+ # An environment's value replaces the top-level one; nothing merges.
113
+ inherited = {key: config[key] for key in INHERITED if key in config}
114
+ return {**inherited, **envs[env]}
115
+
116
+
117
+ def _find_up(start: Path, name: str) -> Path | None:
118
+ for directory in (start, *start.parents):
119
+ if (directory / name).is_file():
120
+ return directory
121
+ return None
122
+
123
+
124
+ def _find_config(cwd: Path) -> Path:
125
+ for name in CONFIG_NAMES:
126
+ directory = _find_up(cwd, name)
127
+ if directory is not None:
128
+ return directory / name
129
+ raise WyndError(f"no Wrangler config ({', '.join(CONFIG_NAMES)}) found from {cwd}")
130
+
131
+
132
+ def _read(path: Path) -> dict[str, Any]:
133
+ try:
134
+ text = path.read_text()
135
+ if path.suffix == ".toml":
136
+ return tomllib.loads(text)
137
+ return jsonc.loads(text)
138
+ except OSError as error:
139
+ raise WyndError(f"cannot read {path}: {error.strerror}") from None
140
+ except (tomllib.TOMLDecodeError, ValueError) as error:
141
+ raise WyndError(f"cannot parse {path}: {error}") from None
wynd/py.typed ADDED
File without changes