workers-py 1.6.1__py3-none-any.whl → 1.7.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.
pywrangler/cli.py CHANGED
@@ -2,13 +2,17 @@ import logging
2
2
  import subprocess
3
3
  import sys
4
4
  import textwrap
5
+ from typing import Never
6
+
5
7
  import click
6
8
 
9
+ from .sync import sync
7
10
  from .utils import (
8
- setup_logging,
9
- write_success,
10
11
  WRANGLER_COMMAND,
11
12
  WRANGLER_CREATE_COMMAND,
13
+ check_wrangler_version,
14
+ setup_logging,
15
+ write_success,
12
16
  )
13
17
 
14
18
  setup_logging()
@@ -16,7 +20,7 @@ logger = logging.getLogger("pywrangler")
16
20
 
17
21
 
18
22
  class ProxyToWranglerGroup(click.Group):
19
- def get_help(self, ctx):
23
+ def get_help(self, ctx: click.Context) -> str:
20
24
  """Override to add custom help content."""
21
25
  # Get the default help text
22
26
  help_text = super().get_help(ctx)
@@ -28,6 +32,7 @@ class ProxyToWranglerGroup(click.Group):
28
32
  capture_output=True,
29
33
  text=True,
30
34
  timeout=10,
35
+ check=False,
31
36
  )
32
37
  if result.returncode == 0:
33
38
  wrangler_help = result.stdout
@@ -47,7 +52,7 @@ class ProxyToWranglerGroup(click.Group):
47
52
 
48
53
  return help_text
49
54
 
50
- def get_command(self, ctx, cmd_name):
55
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command:
51
56
  command = super().get_command(ctx, cmd_name)
52
57
 
53
58
  if command is None:
@@ -58,11 +63,9 @@ class ProxyToWranglerGroup(click.Group):
58
63
  remaining_args = []
59
64
 
60
65
  if cmd_name in ["dev", "publish", "deploy", "versions"]:
61
- ctx.invoke(sync_command, force=False, directly_requested=False)
66
+ sync(force=False)
62
67
 
63
68
  if cmd_name == "dev":
64
- from .sync import check_wrangler_version
65
-
66
69
  check_wrangler_version()
67
70
 
68
71
  if cmd_name == "init":
@@ -78,7 +81,7 @@ class ProxyToWranglerGroup(click.Group):
78
81
  return command
79
82
 
80
83
 
81
- def get_version():
84
+ def get_version() -> str:
82
85
  """Get the version of pywrangler."""
83
86
  try:
84
87
  from importlib.metadata import version
@@ -91,8 +94,7 @@ def get_version():
91
94
  @click.group(cls=ProxyToWranglerGroup)
92
95
  @click.option("--debug", is_flag=True, help="Enable debug logging")
93
96
  @click.version_option(version=get_version(), prog_name="pywrangler")
94
- @click.pass_context
95
- def app(ctx, debug=False):
97
+ def app(debug: bool = False) -> None:
96
98
  """
97
99
  A CLI tool for Cloudflare Workers.
98
100
  Use 'sync' command for Python package setup.
@@ -118,7 +120,7 @@ def app(ctx, debug=False):
118
120
  type=click.Path(exists=True, dir_okay=False, readable=True),
119
121
  help="Path to Wrangler configuration file",
120
122
  )
121
- def types_command(outdir=None, config=None):
123
+ def types_command(outdir: str | None, config: str | None) -> Never:
122
124
  from .types import wrangler_types
123
125
 
124
126
  wrangler_types(outdir, config)
@@ -127,56 +129,17 @@ def types_command(outdir=None, config=None):
127
129
 
128
130
  @app.command("sync")
129
131
  @click.option("--force", is_flag=True, help="Force sync even if no changes detected")
130
- def sync_command(force=False, directly_requested=True):
132
+ def sync_command(force: bool = False) -> None:
131
133
  """
132
134
  Installs Python packages from pyproject.toml into src/vendor.
133
135
 
134
136
  Also creates a virtual env for Workers that you can use for testing.
135
137
  """
136
- # This module is imported locally because it searches for pyproject.toml at the top-level.
137
- from .sync import (
138
- check_requirements_txt,
139
- check_wrangler_config,
140
- is_sync_needed,
141
- create_pyodide_venv,
142
- create_workers_venv,
143
- parse_requirements,
144
- install_requirements,
145
- )
146
-
147
- # Check if requirements.txt does not exist.
148
- check_requirements_txt()
149
-
150
- # Check if sync is needed based on file timestamps
151
- sync_needed = force or is_sync_needed()
152
- if not sync_needed:
153
- if directly_requested:
154
- logger.warning(
155
- "pyproject.toml hasn't changed since last sync, use --force to ignore timestamp check"
156
- )
157
- return
158
-
159
- # Check to make sure a wrangler config file exists.
160
- check_wrangler_config()
161
-
162
- # Create .venv-workers if it doesn't exist
163
- create_workers_venv()
164
-
165
- # Set up Pyodide virtual env
166
- create_pyodide_venv()
167
-
168
- # Generate requirements.txt from pyproject.toml by directly parsing the TOML file then install into vendor folder.
169
- requirements = parse_requirements()
170
- if not requirements:
171
- logger.warning(
172
- "No dependencies found in [project.dependencies] section of pyproject.toml."
173
- )
174
- install_requirements(requirements)
175
-
138
+ sync(force, directly_requested=True)
176
139
  write_success("Sync process completed successfully.")
177
140
 
178
141
 
179
- def _proxy_to_wrangler(command_name, args_list):
142
+ def _proxy_to_wrangler(command_name: str, args_list: list[str]) -> Never:
180
143
  command_to_run = WRANGLER_COMMAND + [command_name] + args_list
181
144
  logger.info(f"Passing command to npx wrangler: {' '.join(command_to_run)}")
182
145
  try:
@@ -189,7 +152,7 @@ def _proxy_to_wrangler(command_name, args_list):
189
152
  click.get_current_context().exit(1)
190
153
 
191
154
 
192
- def _proxy_to_create_cloudflare(args_list):
155
+ def _proxy_to_create_cloudflare(args_list: list[str]) -> Never:
193
156
  command_to_run = WRANGLER_CREATE_COMMAND + args_list
194
157
  logger.info(f"Passing command to npx create-cloudflare: {' '.join(command_to_run)}")
195
158
  try:
pywrangler/metadata.py CHANGED
@@ -1,5 +1,5 @@
1
- from typing import Literal, NamedTuple
2
1
  from datetime import datetime
2
+ from typing import Literal, NamedTuple
3
3
 
4
4
 
5
5
  class PythonCompatVersion(NamedTuple):
pywrangler/py.typed ADDED
File without changes
pywrangler/sync.py CHANGED
@@ -1,43 +1,48 @@
1
1
  import logging
2
2
  import os
3
- import re
4
3
  import shutil
5
4
  import tempfile
5
+ from collections.abc import Iterator
6
6
  from contextlib import contextmanager
7
- from datetime import datetime
8
7
  from pathlib import Path
9
- from typing import Literal
10
8
 
11
9
  import click
12
- import pyjson5
13
10
 
14
11
  from .utils import (
15
- run_command,
12
+ check_uv_version,
13
+ check_wrangler_config,
16
14
  find_pyproject_toml,
15
+ get_project_root,
16
+ get_pyodide_index,
17
+ get_python_version,
18
+ get_uv_pyodide_interp_name,
19
+ read_pyproject_toml,
20
+ run_command,
17
21
  )
18
- from .metadata import PYTHON_COMPAT_VERSIONS
19
-
20
- try:
21
- import tomllib # Standard in Python 3.11+
22
- except ImportError:
23
- import tomli as tomllib # For Python < 3.11
24
22
 
25
23
  logger = logging.getLogger(__name__)
26
24
 
27
- # Define paths
28
- PYPROJECT_TOML_PATH = find_pyproject_toml()
29
- PROJECT_ROOT = PYPROJECT_TOML_PATH.parent
30
- VENV_WORKERS_PATH = PROJECT_ROOT / ".venv-workers"
31
- VENV_WORKERS_TOKEN = PROJECT_ROOT / ".venv-workers/.synced"
32
- PYODIDE_VENV_PATH = VENV_WORKERS_PATH / "pyodide-venv"
33
- VENDOR_TOKEN = PROJECT_ROOT / "python_modules/.synced"
34
- VENV_REQUIREMENTS_PATH = VENV_WORKERS_PATH / "temp-venv-requirements.txt"
25
+
26
+ def get_venv_workers_path() -> Path:
27
+ return get_project_root() / ".venv-workers"
28
+
29
+
30
+ def get_venv_workers_token_path() -> Path:
31
+ return get_venv_workers_path() / ".synced"
35
32
 
36
33
 
37
- def check_requirements_txt():
38
- old_requirements_txt = PROJECT_ROOT / "requirements.txt"
34
+ def get_vendor_token_path() -> Path:
35
+ return get_project_root() / "python_modules/.synced"
36
+
37
+
38
+ def get_pyodide_venv_path() -> Path:
39
+ return get_venv_workers_path() / "pyodide-venv"
40
+
41
+
42
+ def check_requirements_txt() -> None:
43
+ old_requirements_txt = get_project_root() / "requirements.txt"
39
44
  if old_requirements_txt.is_file():
40
- with open(old_requirements_txt, "r") as f:
45
+ with open(old_requirements_txt) as f:
41
46
  requirements = f.read().splitlines()
42
47
  logger.warning(
43
48
  "Specifying Python Packages in requirements.txt is no longer supported, please use pyproject.toml instead.\n"
@@ -54,118 +59,6 @@ def check_requirements_txt():
54
59
  raise click.exceptions.Exit(code=1)
55
60
 
56
61
 
57
- def check_wrangler_config():
58
- wrangler_jsonc = PROJECT_ROOT / "wrangler.jsonc"
59
- wrangler_toml = PROJECT_ROOT / "wrangler.toml"
60
- if not wrangler_jsonc.is_file() and not wrangler_toml.is_file():
61
- logger.error(
62
- f"{wrangler_jsonc} or {wrangler_toml} not found in {PROJECT_ROOT}."
63
- )
64
- raise click.exceptions.Exit(code=1)
65
-
66
-
67
- def _parse_wrangler_config() -> dict:
68
- """
69
- Parse wrangler configuration from either wrangler.toml or wrangler.jsonc.
70
-
71
- Returns:
72
- dict: Parsed configuration data
73
- """
74
- wrangler_toml = PROJECT_ROOT / "wrangler.toml"
75
- wrangler_jsonc = PROJECT_ROOT / "wrangler.jsonc"
76
-
77
- if wrangler_toml.is_file():
78
- try:
79
- with open(wrangler_toml, "rb") as f:
80
- return tomllib.load(f)
81
- except tomllib.TOMLDecodeError as e:
82
- logger.error(f"Error parsing {wrangler_toml}: {e}")
83
- raise click.exceptions.Exit(code=1)
84
-
85
- if wrangler_jsonc.is_file():
86
- try:
87
- with open(wrangler_jsonc, "r") as f:
88
- content = f.read()
89
- return pyjson5.loads(content)
90
- except (pyjson5.Json5DecoderError, ValueError) as e:
91
- logger.error(f"Error parsing {wrangler_jsonc}: {e}")
92
- raise click.exceptions.Exit(code=1)
93
-
94
- return {}
95
-
96
-
97
- def _get_python_version() -> Literal["3.12", "3.13"]:
98
- """
99
- Determine Python version from wrangler configuration.
100
-
101
- Returns:
102
- Python version string
103
- """
104
- config = _parse_wrangler_config()
105
-
106
- if not config:
107
- logger.error("No wrangler config found")
108
- raise click.exceptions.Exit(code=1)
109
-
110
- compat_flags = config.get("compatibility_flags", [])
111
-
112
- if "compatibility_date" not in config:
113
- logger.error("No compatibility_date specified in wrangler config")
114
- raise click.exceptions.Exit(code=1)
115
- try:
116
- compat_date = datetime.strptime(config.get("compatibility_date"), "%Y-%m-%d")
117
- except ValueError:
118
- logger.error(
119
- f"Invalid compatibility_date format: {config.get('compatibility_date')}"
120
- )
121
- raise click.exceptions.Exit(code=1)
122
-
123
- # Check if python_workers base flag is present (required for Python workers)
124
- if "python_workers" not in compat_flags:
125
- logger.error("`python_workers` compat flag not specified in wrangler config")
126
- raise click.exceptions.Exit(code=1)
127
-
128
- # Find the most specific Python version based on compat flags and date
129
- # Sort by version descending to prioritize newer versions
130
- sorted_versions = sorted(
131
- PYTHON_COMPAT_VERSIONS, key=lambda x: x.version, reverse=True
132
- )
133
-
134
- for py_version in sorted_versions:
135
- # Check if the specific compat flag is present
136
- if py_version.compat_flag in compat_flags:
137
- return py_version.version
138
-
139
- # For versions with compat_date, also check the date requirement
140
- if (
141
- py_version.compat_date
142
- and compat_date
143
- and compat_date >= py_version.compat_date
144
- ):
145
- return py_version.version
146
-
147
- logger.error("Could not determine Python version from wrangler config")
148
- raise click.exceptions.Exit(code=1)
149
-
150
-
151
- def _get_uv_pyodide_interp_name():
152
- match _get_python_version():
153
- case "3.12":
154
- v = "3.12.7"
155
- case "3.13":
156
- v = "3.13.2"
157
- return f"cpython-{v}-emscripten-wasm32-musl"
158
-
159
-
160
- def _get_pyodide_index():
161
- match _get_python_version():
162
- case "3.12":
163
- v = "0.27.7"
164
- case "3.13":
165
- v = "0.28.3"
166
- return "https://index.pyodide.org/" + v
167
-
168
-
169
62
  def _get_venv_python_version() -> str | None:
170
63
  """
171
64
  Retrieves the Python version from the virtual environment.
@@ -173,10 +66,11 @@ def _get_venv_python_version() -> str | None:
173
66
  Returns:
174
67
  The Python version string or None if it cannot be determined.
175
68
  """
69
+ venv_workers_path = get_venv_workers_path()
176
70
  venv_python = (
177
- VENV_WORKERS_PATH / "Scripts" / "python.exe"
71
+ venv_workers_path / "Scripts" / "python.exe"
178
72
  if os.name == "nt"
179
- else VENV_WORKERS_PATH / "bin" / "python"
73
+ else venv_workers_path / "bin" / "python"
180
74
  )
181
75
  if not venv_python.is_file():
182
76
  return None
@@ -190,136 +84,74 @@ def _get_venv_python_version() -> str | None:
190
84
  return result.stdout.strip()
191
85
 
192
86
 
193
- def create_workers_venv():
87
+ def create_workers_venv() -> None:
194
88
  """
195
- Creates a virtual environment at `VENV_WORKERS_PATH` if it doesn't exist.
89
+ Creates a virtual environment at `venv_workers_path` if it doesn't exist.
196
90
  """
197
- wanted_python_version = _get_python_version()
91
+ wanted_python_version = get_python_version()
198
92
  logger.debug(f"Using python version from wrangler config: {wanted_python_version}")
199
93
 
200
- if VENV_WORKERS_PATH.is_dir():
94
+ venv_workers_path = get_venv_workers_path()
95
+ if venv_workers_path.is_dir():
201
96
  installed_version = _get_venv_python_version()
202
97
  if installed_version:
203
98
  if wanted_python_version in installed_version:
204
99
  logger.debug(
205
- f"Virtual environment at {VENV_WORKERS_PATH} already exists."
100
+ f"Virtual environment at {venv_workers_path} already exists."
206
101
  )
207
102
  return
208
103
 
209
104
  logger.warning(
210
- f"Recreating virtual environment at {VENV_WORKERS_PATH} due to Python version mismatch. "
105
+ f"Recreating virtual environment at {venv_workers_path} due to Python version mismatch. "
211
106
  f"Found {installed_version}, expected {wanted_python_version}"
212
107
  )
213
108
  else:
214
109
  logger.warning(
215
- f"Could not determine python version for {VENV_WORKERS_PATH}, recreating."
110
+ f"Could not determine python version for {venv_workers_path}, recreating."
216
111
  )
217
112
 
218
- shutil.rmtree(VENV_WORKERS_PATH)
113
+ shutil.rmtree(venv_workers_path)
219
114
 
220
- logger.debug(f"Creating virtual environment at {VENV_WORKERS_PATH}...")
115
+ logger.debug(f"Creating virtual environment at {venv_workers_path}...")
221
116
  run_command(
222
117
  [
223
118
  "uv",
224
119
  "venv",
225
- str(VENV_WORKERS_PATH),
120
+ str(venv_workers_path),
226
121
  "--python",
227
122
  f"python{wanted_python_version}",
228
123
  ]
229
124
  )
230
125
 
231
126
 
232
- MIN_UV_VERSION = (0, 8, 10)
233
- MIN_WRANGLER_VERSION = (4, 42, 1)
234
-
235
-
236
- def check_uv_version():
237
- res = run_command(["uv", "--version"], capture_output=True)
238
- ver_str = res.stdout.split(" ")[1]
239
- ver = tuple(int(x) for x in ver_str.split("."))
240
- if ver >= MIN_UV_VERSION:
241
- return
242
- min_version_str = ".".join(str(x) for x in MIN_UV_VERSION)
243
- logger.error(f"uv version at least {min_version_str} required, have {ver_str}.")
244
- logger.error("Update uv with `uv self update`.")
245
- raise click.exceptions.Exit(code=1)
246
-
247
-
248
- def check_wrangler_version():
249
- """
250
- Check that the installed wrangler version is at least 4.42.1.
251
-
252
- Raises:
253
- click.exceptions.Exit: If wrangler is not installed or version is too old.
254
- """
255
- result = run_command(
256
- ["npx", "--yes", "wrangler", "--version"], capture_output=True, check=False
257
- )
258
- if result.returncode != 0:
259
- logger.error("Failed to get wrangler version. Is wrangler installed?")
260
- logger.error("Install wrangler with: npm install wrangler@latest")
261
- raise click.exceptions.Exit(code=1)
262
-
263
- # Parse version from output like "wrangler 4.42.1" or " ⛅️ wrangler 4.42.1"
264
- version_line = result.stdout.strip()
265
- # Extract version number using regex
266
- version_match = re.search(r"(\d+)\.(\d+)\.(\d+)", version_line)
267
-
268
- if not version_match:
269
- logger.error(f"Could not parse wrangler version from: {version_line}")
270
- logger.error("Install wrangler with: npm install wrangler@latest")
271
- raise click.exceptions.Exit(code=1)
272
-
273
- major, minor, patch = map(int, version_match.groups())
274
- current_version = (major, minor, patch)
275
-
276
- if current_version < MIN_WRANGLER_VERSION:
277
- min_version_str = ".".join(str(x) for x in MIN_WRANGLER_VERSION)
278
- current_version_str = ".".join(str(x) for x in current_version)
279
- logger.error(
280
- f"wrangler version at least {min_version_str} required, have {current_version_str}."
281
- )
282
- logger.error("Update wrangler with: npm install wrangler@latest")
283
- raise click.exceptions.Exit(code=1)
284
-
285
- logger.debug(
286
- f"wrangler version {'.'.join(str(x) for x in current_version)} is sufficient"
287
- )
288
-
289
-
290
- def create_pyodide_venv():
291
- if PYODIDE_VENV_PATH.is_dir():
127
+ def create_pyodide_venv() -> None:
128
+ pyodide_venv_path = get_pyodide_venv_path()
129
+ if pyodide_venv_path.is_dir():
292
130
  logger.debug(
293
- f"Pyodide virtual environment at {PYODIDE_VENV_PATH} already exists."
131
+ f"Pyodide virtual environment at {pyodide_venv_path} already exists."
294
132
  )
295
133
  return
296
134
 
297
135
  check_uv_version()
298
- logger.debug(f"Creating Pyodide virtual environment at {PYODIDE_VENV_PATH}...")
299
- PYODIDE_VENV_PATH.parent.mkdir(parents=True, exist_ok=True)
300
- interp_name = _get_uv_pyodide_interp_name()
136
+ logger.debug(f"Creating Pyodide virtual environment at {pyodide_venv_path}...")
137
+ pyodide_venv_path.parent.mkdir(parents=True, exist_ok=True)
138
+ interp_name = get_uv_pyodide_interp_name()
301
139
  run_command(["uv", "python", "install", interp_name])
302
- run_command(["uv", "venv", PYODIDE_VENV_PATH, "--python", interp_name])
140
+ run_command(["uv", "venv", pyodide_venv_path, "--python", interp_name])
303
141
 
304
142
 
305
143
  def parse_requirements() -> list[str]:
306
- logger.debug(f"Reading dependencies from {PYPROJECT_TOML_PATH}...")
307
- try:
308
- with open(PYPROJECT_TOML_PATH, "rb") as f:
309
- pyproject_data = tomllib.load(f)
310
-
311
- # Extract dependencies from [project.dependencies]
312
- dependencies = pyproject_data.get("project", {}).get("dependencies", [])
313
-
314
- logger.info(f"Found {len(dependencies)} dependencies.")
315
- return dependencies
316
- except tomllib.TOMLDecodeError as e:
317
- logger.error(f"Error parsing {PYPROJECT_TOML_PATH}: {str(e)}")
318
- raise click.exceptions.Exit(code=1)
144
+ pyproject_data = read_pyproject_toml()
145
+
146
+ # Extract dependencies from [project.dependencies]
147
+ dependencies = pyproject_data.get("project", {}).get("dependencies", [])
148
+
149
+ logger.info(f"Found {len(dependencies)} dependencies.")
150
+ return dependencies
319
151
 
320
152
 
321
153
  @contextmanager
322
- def temp_requirements_file(requirements: list[str]):
154
+ def temp_requirements_file(requirements: list[str]) -> Iterator[str]:
323
155
  # Write dependencies to a requirements.txt-style temp file.
324
156
  with tempfile.NamedTemporaryFile(mode="w", suffix=".txt") as temp_file:
325
157
  temp_file.write("\n".join(requirements))
@@ -327,8 +159,8 @@ def temp_requirements_file(requirements: list[str]):
327
159
  yield temp_file.name
328
160
 
329
161
 
330
- def _install_requirements_to_vendor(requirements: list[str]):
331
- vendor_path = PROJECT_ROOT / "python_modules"
162
+ def _install_requirements_to_vendor(requirements: list[str]) -> None:
163
+ vendor_path = get_project_root() / "python_modules"
332
164
  logger.debug(f"Using vendor path: {vendor_path}")
333
165
 
334
166
  if len(requirements) == 0:
@@ -339,13 +171,13 @@ def _install_requirements_to_vendor(requirements: list[str]):
339
171
 
340
172
  # Install packages into vendor directory
341
173
  vendor_path.mkdir(parents=True, exist_ok=True)
342
- relative_vendor_path = vendor_path.relative_to(PROJECT_ROOT)
174
+ relative_vendor_path = vendor_path.relative_to(get_project_root())
343
175
  logger.info(
344
176
  f"Installing packages into [bold]{relative_vendor_path}[/bold]...",
345
177
  extra={"markup": True},
346
178
  )
347
179
  with temp_requirements_file(requirements) as requirements_file:
348
- run_command(
180
+ result = run_command(
349
181
  [
350
182
  "uv",
351
183
  "pip",
@@ -354,21 +186,44 @@ def _install_requirements_to_vendor(requirements: list[str]):
354
186
  "-r",
355
187
  requirements_file,
356
188
  "--extra-index-url",
357
- _get_pyodide_index(),
189
+ get_pyodide_index(),
358
190
  "--index-strategy",
359
191
  "unsafe-best-match",
360
192
  ],
361
- env=os.environ | {"VIRTUAL_ENV": PYODIDE_VENV_PATH},
193
+ capture_output=True,
194
+ check=False,
195
+ env=os.environ | {"VIRTUAL_ENV": get_pyodide_venv_path()},
362
196
  )
363
- pyv = _get_python_version()
197
+ if result.returncode != 0:
198
+ logger.warning(result.stdout.strip())
199
+ # Handle some common failures and give nicer error messages for them.
200
+ lowered_stdout = result.stdout.lower()
201
+ if "invalid peer certificate" in lowered_stdout:
202
+ logger.error(
203
+ "Installation failed because of an invalid peer certificate. Are your systems certificates correctly installed? Do you have an Enterprise VPN enabled?"
204
+ )
205
+ elif "failed to fetch" in lowered_stdout:
206
+ logger.error(
207
+ "Installation failed because of a failed fetch. Is your network connection working?"
208
+ )
209
+ elif "no solution found when resolving dependencies" in lowered_stdout:
210
+ logger.error(
211
+ "Installation failed because the packages you requested are not supported by Python Workers. See above for details."
212
+ )
213
+ else:
214
+ logger.error(
215
+ "Installation of packages into the Python Worker failed. Possibly because these packages are not currently supported. See above for details."
216
+ )
217
+ raise click.exceptions.Exit(code=result.returncode)
218
+ pyv = get_python_version()
364
219
  shutil.rmtree(vendor_path)
365
220
  shutil.copytree(
366
- PYODIDE_VENV_PATH / f"lib/python{pyv}/site-packages", vendor_path
221
+ get_pyodide_venv_path() / f"lib/python{pyv}/site-packages", vendor_path
367
222
  )
368
223
 
369
224
  # Create a pyvenv.cfg file in python_modules to mark it as a virtual environment
370
225
  (vendor_path / "pyvenv.cfg").touch()
371
- VENDOR_TOKEN.touch()
226
+ get_vendor_token_path().touch()
372
227
 
373
228
  logger.info(
374
229
  f"Packages installed in [bold]{relative_vendor_path}[/bold].",
@@ -376,9 +231,11 @@ def _install_requirements_to_vendor(requirements: list[str]):
376
231
  )
377
232
 
378
233
 
379
- def _install_requirements_to_venv(requirements: list[str]):
234
+ def _install_requirements_to_venv(requirements: list[str]) -> None:
380
235
  # Create a requirements file for .venv-workers that includes pyodide-py
381
- relative_venv_workers_path = VENV_WORKERS_PATH.relative_to(PROJECT_ROOT)
236
+ venv_workers_path = get_venv_workers_path()
237
+ project_root = get_project_root()
238
+ relative_venv_workers_path = venv_workers_path.relative_to(project_root)
382
239
  requirements = requirements.copy()
383
240
  requirements.append("pyodide-py")
384
241
 
@@ -387,7 +244,7 @@ def _install_requirements_to_venv(requirements: list[str]):
387
244
  extra={"markup": True},
388
245
  )
389
246
  with temp_requirements_file(requirements) as requirements_file:
390
- run_command(
247
+ result = run_command(
391
248
  [
392
249
  "uv",
393
250
  "pip",
@@ -395,18 +252,33 @@ def _install_requirements_to_venv(requirements: list[str]):
395
252
  "-r",
396
253
  requirements_file,
397
254
  ],
398
- env=os.environ | {"VIRTUAL_ENV": VENV_WORKERS_PATH},
255
+ check=False,
256
+ env=os.environ | {"VIRTUAL_ENV": venv_workers_path},
257
+ capture_output=True,
399
258
  )
400
- VENV_WORKERS_TOKEN.touch()
259
+ if result.returncode != 0:
260
+ logger.warning(result.stdout.strip())
261
+ logger.error(
262
+ "Failed to install the requirements defined in your pyproject.toml file. See above for details."
263
+ )
264
+ raise click.exceptions.Exit(code=result.returncode)
265
+
266
+ get_venv_workers_token_path().touch()
401
267
  logger.info(
402
268
  f"Packages installed in [bold]{relative_venv_workers_path}[/bold].",
403
269
  extra={"markup": True},
404
270
  )
405
271
 
406
272
 
407
- def install_requirements(requirements: list[str]):
408
- _install_requirements_to_vendor(requirements)
273
+ def install_requirements(requirements: list[str]) -> None:
274
+ # Note: the order these are executed is important.
275
+ # We need to install to .venv-workers first, so that we can determine if the packages requested
276
+ # by the user are valid.
409
277
  _install_requirements_to_venv(requirements)
278
+ # Then we install the same requirements to the vendor directory. If this installation
279
+ # fails while the above succeeded, it implies that Pyodide does not support these package
280
+ # requirements which allows us to give a nicer error message to the user.
281
+ _install_requirements_to_vendor(requirements)
410
282
 
411
283
 
412
284
  def _is_out_of_date(token: Path, time: float) -> bool:
@@ -415,19 +287,50 @@ def _is_out_of_date(token: Path, time: float) -> bool:
415
287
  return time > token.stat().st_mtime
416
288
 
417
289
 
418
- def is_sync_needed():
290
+ def is_sync_needed() -> bool:
419
291
  """
420
292
  Checks if pyproject.toml has been modified since the last sync.
421
293
 
422
294
  Returns:
423
295
  bool: True if sync is needed, False otherwise
424
296
  """
425
-
426
- if not PYPROJECT_TOML_PATH.is_file():
297
+ pyproject_toml_path = find_pyproject_toml()
298
+ if not pyproject_toml_path.is_file():
427
299
  # If pyproject.toml doesn't exist, we need to abort anyway
428
300
  return True
429
301
 
430
- pyproject_mtime = PYPROJECT_TOML_PATH.stat().st_mtime
431
- return _is_out_of_date(VENDOR_TOKEN, pyproject_mtime) or _is_out_of_date(
432
- VENV_WORKERS_TOKEN, pyproject_mtime
302
+ pyproject_mtime = pyproject_toml_path.stat().st_mtime
303
+ return _is_out_of_date(get_vendor_token_path(), pyproject_mtime) or _is_out_of_date(
304
+ get_venv_workers_token_path(), pyproject_mtime
433
305
  )
306
+
307
+
308
+ def sync(force: bool = False, directly_requested: bool = False) -> None:
309
+ # Check if requirements.txt does not exist.
310
+ check_requirements_txt()
311
+
312
+ # Check if sync is needed based on file timestamps
313
+ sync_needed = force or is_sync_needed()
314
+ if not sync_needed:
315
+ if directly_requested:
316
+ logger.warning(
317
+ "pyproject.toml hasn't changed since last sync, use --force to ignore timestamp check"
318
+ )
319
+ return
320
+
321
+ # Check to make sure a wrangler config file exists.
322
+ check_wrangler_config()
323
+
324
+ # Create .venv-workers if it doesn't exist
325
+ create_workers_venv()
326
+
327
+ # Set up Pyodide virtual env
328
+ create_pyodide_venv()
329
+
330
+ # Generate requirements.txt from pyproject.toml by directly parsing the TOML file then install into vendor folder.
331
+ requirements = parse_requirements()
332
+ if not requirements:
333
+ logger.warning(
334
+ "No dependencies found in [project.dependencies] section of pyproject.toml."
335
+ )
336
+ install_requirements(requirements)
pywrangler/types.py CHANGED
@@ -1,7 +1,8 @@
1
- from .utils import WRANGLER_COMMAND, run_command
2
- from tempfile import TemporaryDirectory
3
- from pathlib import Path
4
1
  import logging
2
+ from pathlib import Path
3
+ from tempfile import TemporaryDirectory
4
+
5
+ from .utils import WRANGLER_COMMAND, run_command
5
6
 
6
7
  logger = logging.getLogger(__name__)
7
8
 
@@ -26,7 +27,7 @@ PACKAGE_JSON = """
26
27
  """
27
28
 
28
29
 
29
- def wrangler_types(outdir_arg: str | None, config: str | None, /):
30
+ def wrangler_types(outdir_arg: str | None, config: str | None, /) -> None:
30
31
  args = ["types"]
31
32
  if config:
32
33
  args += ["--config", config]
pywrangler/utils.py CHANGED
@@ -1,11 +1,20 @@
1
1
  import logging
2
+ import re
2
3
  import subprocess
4
+ import tomllib
5
+ from datetime import datetime
6
+ from functools import cache
3
7
  from pathlib import Path
8
+ from typing import Literal, TypedDict, cast
4
9
 
5
10
  import click
6
- from rich.logging import Console, RichHandler
11
+ import pyjson5
12
+ from rich.console import Console
13
+ from rich.logging import RichHandler
7
14
  from rich.theme import Theme
8
15
 
16
+ from .metadata import PYTHON_COMPAT_VERSIONS
17
+
9
18
  WRANGLER_COMMAND = ["npx", "--yes", "wrangler"]
10
19
  WRANGLER_CREATE_COMMAND = ["npx", "--yes", "create-cloudflare"]
11
20
 
@@ -16,7 +25,7 @@ RUNNING_LEVEL = 15
16
25
  OUTPUT_LEVEL = 16
17
26
 
18
27
 
19
- def setup_logging():
28
+ def setup_logging() -> None:
20
29
  console = Console(
21
30
  theme=Theme(
22
31
  {
@@ -44,17 +53,17 @@ def setup_logging():
44
53
  logging.addLevelName(OUTPUT_LEVEL, "OUTPUT")
45
54
 
46
55
 
47
- def write_success(msg):
56
+ def write_success(msg: str) -> None:
48
57
  logging.log(SUCCESS_LEVEL, msg)
49
58
 
50
59
 
51
60
  def run_command(
52
61
  command: list[str | Path],
53
62
  cwd: Path | None = None,
54
- env: dict | None = None,
63
+ env: dict[str, str | Path] | None = None,
55
64
  check: bool = True,
56
65
  capture_output: bool = False,
57
- ):
66
+ ) -> subprocess.CompletedProcess[str]:
58
67
  """
59
68
  Runs a command and handles logging and errors.
60
69
 
@@ -70,27 +79,32 @@ def run_command(
70
79
  """
71
80
  logger.log(RUNNING_LEVEL, f"{' '.join(str(arg) for arg in command)}")
72
81
  try:
82
+ kwargs = {}
83
+ if capture_output:
84
+ kwargs = dict(stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
85
+
73
86
  process = subprocess.run(
74
87
  command,
75
88
  cwd=cwd,
76
89
  env=env,
77
90
  check=check,
78
- capture_output=capture_output,
79
91
  text=True,
80
- )
92
+ **kwargs,
93
+ ) # type: ignore[call-overload]
81
94
  if process.stdout and not capture_output:
82
95
  logger.log(OUTPUT_LEVEL, f"{process.stdout.strip()}")
83
- return process
96
+ return process # type: ignore[no-any-return]
84
97
  except subprocess.CalledProcessError as e:
85
98
  logger.error(
86
- f"Error running command: {' '.join(str(arg) for arg in command)}\nExit code: {e.returncode}\nOutput:\n{e.stdout.strip() if e.stdout else ''}{e.stderr.strip() if e.stderr else ''}"
99
+ f"Error running command: {' '.join(str(arg) for arg in command)}\nExit code: {e.returncode}\nOutput:\n{e.stdout.strip() if e.stdout else ''}"
87
100
  )
88
- raise click.exceptions.Exit(code=e.returncode)
101
+ raise click.exceptions.Exit(code=e.returncode) from None
89
102
  except FileNotFoundError:
90
103
  logger.error(f"Command not found: {command[0]}. Is it installed and in PATH?")
91
- raise click.exceptions.Exit(code=1)
104
+ raise click.exceptions.Exit(code=1) from None
92
105
 
93
106
 
107
+ @cache
94
108
  def find_pyproject_toml() -> Path:
95
109
  """
96
110
  Search for pyproject.toml starting from current working directory and going up the directory tree.
@@ -112,3 +126,200 @@ def find_pyproject_toml() -> Path:
112
126
  f"pyproject.toml not found in {Path.cwd().resolve()} or any parent directories"
113
127
  )
114
128
  raise click.exceptions.Exit(code=1)
129
+
130
+
131
+ class PyProjectProject(TypedDict):
132
+ dependencies: list[str]
133
+
134
+
135
+ class PyProject(TypedDict):
136
+ project: PyProjectProject
137
+
138
+
139
+ def read_pyproject_toml() -> PyProject:
140
+ pyproject_toml = find_pyproject_toml()
141
+ logger.debug(f"Reading {pyproject_toml}...")
142
+ try:
143
+ with open(pyproject_toml, "rb") as f:
144
+ return cast(PyProject, tomllib.load(f))
145
+ except tomllib.TOMLDecodeError as e:
146
+ logger.error(f"Error parsing {pyproject_toml}: {str(e)}")
147
+ raise click.exceptions.Exit(code=1) from None
148
+
149
+
150
+ def get_project_root() -> Path:
151
+ return find_pyproject_toml().parent
152
+
153
+
154
+ MIN_UV_VERSION = (0, 8, 10)
155
+ MIN_WRANGLER_VERSION = (4, 42, 1)
156
+
157
+
158
+ def check_uv_version() -> None:
159
+ res = run_command(["uv", "--version"], capture_output=True)
160
+ ver_str = res.stdout.split(" ")[1]
161
+ ver = tuple(int(x) for x in ver_str.split("."))
162
+ if ver >= MIN_UV_VERSION:
163
+ return
164
+ min_version_str = ".".join(str(x) for x in MIN_UV_VERSION)
165
+ logger.error(f"uv version at least {min_version_str} required, have {ver_str}.")
166
+ logger.error("Update uv with `uv self update`.")
167
+ raise click.exceptions.Exit(code=1)
168
+
169
+
170
+ def check_wrangler_version() -> None:
171
+ """
172
+ Check that the installed wrangler version is at least 4.42.1.
173
+
174
+ Raises:
175
+ click.exceptions.Exit: If wrangler is not installed or version is too old.
176
+ """
177
+ result = run_command(
178
+ ["npx", "--yes", "wrangler", "--version"], capture_output=True, check=False
179
+ )
180
+ if result.returncode != 0:
181
+ logger.error("Failed to get wrangler version. Is wrangler installed?")
182
+ logger.error("Install wrangler with: npm install wrangler@latest")
183
+ raise click.exceptions.Exit(code=1)
184
+
185
+ # Parse version from output like "wrangler 4.42.1" or " ⛅️ wrangler 4.42.1"
186
+ version_line = result.stdout.strip()
187
+ # Extract version number using regex
188
+ version_match = re.search(r"(\d+)\.(\d+)\.(\d+)", version_line)
189
+
190
+ if not version_match:
191
+ logger.error(f"Could not parse wrangler version from: {version_line}")
192
+ logger.error("Install wrangler with: npm install wrangler@latest")
193
+ raise click.exceptions.Exit(code=1)
194
+
195
+ major, minor, patch = map(int, version_match.groups())
196
+ current_version = (major, minor, patch)
197
+
198
+ if current_version < MIN_WRANGLER_VERSION:
199
+ min_version_str = ".".join(str(x) for x in MIN_WRANGLER_VERSION)
200
+ current_version_str = ".".join(str(x) for x in current_version)
201
+ logger.error(
202
+ f"wrangler version at least {min_version_str} required, have {current_version_str}."
203
+ )
204
+ logger.error("Update wrangler with: npm install wrangler@latest")
205
+ raise click.exceptions.Exit(code=1)
206
+
207
+ logger.debug(
208
+ f"wrangler version {'.'.join(str(x) for x in current_version)} is sufficient"
209
+ )
210
+
211
+
212
+ def check_wrangler_config() -> None:
213
+ PROJECT_ROOT = get_project_root()
214
+ wrangler_jsonc = PROJECT_ROOT / "wrangler.jsonc"
215
+ wrangler_toml = PROJECT_ROOT / "wrangler.toml"
216
+ if not wrangler_jsonc.is_file() and not wrangler_toml.is_file():
217
+ logger.error(
218
+ f"{wrangler_jsonc} or {wrangler_toml} not found in {PROJECT_ROOT}."
219
+ )
220
+ raise click.exceptions.Exit(code=1)
221
+
222
+
223
+ class WranglerConfig(TypedDict, total=False):
224
+ compatibility_date: str
225
+ compatibility_flags: list[str]
226
+
227
+
228
+ def _parse_wrangler_config() -> WranglerConfig:
229
+ """
230
+ Parse wrangler configuration from either wrangler.toml or wrangler.jsonc.
231
+
232
+ Returns:
233
+ dict: Parsed configuration data
234
+ """
235
+ PROJECT_ROOT = get_project_root()
236
+ wrangler_toml = PROJECT_ROOT / "wrangler.toml"
237
+ wrangler_jsonc = PROJECT_ROOT / "wrangler.jsonc"
238
+
239
+ if wrangler_toml.is_file():
240
+ try:
241
+ with open(wrangler_toml, "rb") as f:
242
+ return cast(WranglerConfig, tomllib.load(f))
243
+ except tomllib.TOMLDecodeError as e:
244
+ logger.error(f"Error parsing {wrangler_toml}: {e}")
245
+ raise click.exceptions.Exit(code=1) from None
246
+
247
+ if wrangler_jsonc.is_file():
248
+ try:
249
+ with open(wrangler_jsonc) as f:
250
+ content = f.read()
251
+ return cast(WranglerConfig, pyjson5.loads(content))
252
+ except (pyjson5.Json5DecoderException, ValueError) as e:
253
+ logger.error(f"Error parsing {wrangler_jsonc}: {e}")
254
+ raise click.exceptions.Exit(code=1) from None
255
+
256
+ return {}
257
+
258
+
259
+ @cache
260
+ def get_python_version() -> Literal["3.12", "3.13"]:
261
+ """
262
+ Determine Python version from wrangler configuration.
263
+
264
+ Returns:
265
+ Python version string
266
+ """
267
+ config = _parse_wrangler_config()
268
+
269
+ if not config:
270
+ logger.error("No wrangler config found")
271
+ raise click.exceptions.Exit(code=1)
272
+
273
+ compat_flags = config.get("compatibility_flags", [])
274
+ compat_date_str = config.get("compatibility_date", None)
275
+ if compat_date_str is None:
276
+ logger.error("No compatibility_date specified in wrangler config")
277
+ raise click.exceptions.Exit(code=1)
278
+ try:
279
+ compat_date = datetime.strptime(compat_date_str, "%Y-%m-%d")
280
+ except ValueError:
281
+ logger.error(
282
+ f"Invalid compatibility_date format: {config.get('compatibility_date')}"
283
+ )
284
+ raise click.exceptions.Exit(code=1) from None
285
+
286
+ # Check if python_workers base flag is present (required for Python workers)
287
+ if "python_workers" not in compat_flags:
288
+ logger.error("`python_workers` compat flag not specified in wrangler config")
289
+ raise click.exceptions.Exit(code=1)
290
+
291
+ # Find the most specific Python version based on compat flags and date
292
+ # Sort by version descending to prioritize newer versions
293
+ sorted_versions = sorted(
294
+ PYTHON_COMPAT_VERSIONS, key=lambda x: x.version, reverse=True
295
+ )
296
+
297
+ for py_version in sorted_versions:
298
+ # Check if the specific compat flag is present
299
+ if py_version.compat_flag in compat_flags:
300
+ return py_version.version
301
+
302
+ # For versions with compat_date, also check the date requirement
303
+ if py_version.compat_date and compat_date >= py_version.compat_date:
304
+ return py_version.version
305
+
306
+ logger.error("Could not determine Python version from wrangler config")
307
+ raise click.exceptions.Exit(code=1)
308
+
309
+
310
+ def get_uv_pyodide_interp_name() -> str:
311
+ match get_python_version():
312
+ case "3.12":
313
+ v = "3.12.7"
314
+ case "3.13":
315
+ v = "3.13.2"
316
+ return f"cpython-{v}-emscripten-wasm32-musl"
317
+
318
+
319
+ def get_pyodide_index() -> str:
320
+ match get_python_version():
321
+ case "3.12":
322
+ v = "0.27.7"
323
+ case "3.13":
324
+ v = "0.28.3"
325
+ return "https://index.pyodide.org/" + v
@@ -1,18 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workers-py
3
- Version: 1.6.1
3
+ Version: 1.7.0
4
4
  Summary: A set of libraries and tools for Python Workers
5
5
  Project-URL: Homepage, https://github.com/cloudflare/workers-py
6
6
  Project-URL: Bug Tracker, https://github.com/cloudflare/workers-py/issues
7
7
  Classifier: License :: OSI Approved :: MIT License
8
8
  Classifier: Operating System :: OS Independent
9
9
  Classifier: Programming Language :: Python :: 3
10
- Requires-Python: >=3.10
10
+ Requires-Python: >=3.12
11
11
  Requires-Dist: click<9.0.0,>=8.0.0
12
12
  Requires-Dist: pyjson5>=1.6.0
13
13
  Requires-Dist: pyodide-cli
14
14
  Requires-Dist: pyodide-py
15
15
  Requires-Dist: rich>=13.0.0
16
+ Requires-Dist: workers-runtime-sdk>=0.1.0
16
17
  Provides-Extra: build
17
18
  Requires-Dist: uv~=0.5.23; extra == 'build'
18
19
  Description-Content-Type: text/markdown
@@ -0,0 +1,12 @@
1
+ pywrangler/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pywrangler/__main__.py,sha256=wcCrL4PjG51r5wVKqJhcoJPTLfHW0wNbD31DrUN0MWI,28
3
+ pywrangler/cli.py,sha256=8C4iBntruyMkQqOb0KfH_H0QNGYYWEBtKFA7b7zGSmE,5467
4
+ pywrangler/metadata.py,sha256=ndh584ALzshSXwduTmqVczfF5Mpn7z0F9ztn3Dugf70,408
5
+ pywrangler/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ pywrangler/sync.py,sha256=TuM_bmBdgi37xYFtD62GTNwyNaDgO87QwfS-OYLR8OM,11647
7
+ pywrangler/types.py,sha256=hYJ6hNIjWb0faBGW82AWfCBsF_JPb7sXKCXPtFM3Mhk,1183
8
+ pywrangler/utils.py,sha256=mPY8LcRqYMuyVzyWS_077-4MX3K6sIThV1gY8_br9U0,10495
9
+ workers_py-1.7.0.dist-info/METADATA,sha256=z6R2A_5LLb2hE5xY6NOpqXxX0ooA3kexOp1BIho7qlc,1774
10
+ workers_py-1.7.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ workers_py-1.7.0.dist-info/entry_points.txt,sha256=pt6X-Nv5-gSiKUwrnvLwzlSXs9yP37m7zdTAi8f6nAM,50
12
+ workers_py-1.7.0.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- pywrangler/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- pywrangler/__main__.py,sha256=wcCrL4PjG51r5wVKqJhcoJPTLfHW0wNbD31DrUN0MWI,28
3
- pywrangler/cli.py,sha256=voUaP2tHCwxznzZ45O6ZAJ91vbp6q9UNb7H2OlgnZ_k,6596
4
- pywrangler/metadata.py,sha256=vttmaCtouSr9ADj8ncvNGqeaWEGFP8pamH2T6ohFjnA,408
5
- pywrangler/sync.py,sha256=fFavYhbvBXF4memxrJLnMJpApET_3fx9NmJ_O6NDzxw,14217
6
- pywrangler/types.py,sha256=RyOTVFFC75vJ0NxGzXWJGouRpQ5k4gsf3Jsiq-i_ans,1174
7
- pywrangler/utils.py,sha256=JOPCXy_O_RZ-ClSSWL5pel2Iltyclj_JV13IxKr_WRo,3382
8
- workers_py-1.6.1.dist-info/METADATA,sha256=0oipVsEgOgZhdUXJp9ckB7HWqGVvqqsh2sbcQBL1yIY,1732
9
- workers_py-1.6.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
- workers_py-1.6.1.dist-info/entry_points.txt,sha256=pt6X-Nv5-gSiKUwrnvLwzlSXs9yP37m7zdTAi8f6nAM,50
11
- workers_py-1.6.1.dist-info/RECORD,,