workers-py 1.6.0__py3-none-any.whl → 1.6.2__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/__main__.py CHANGED
@@ -1,3 +1,3 @@
1
- from pywrangler.cli import app
1
+ from .cli import app
2
2
 
3
3
  app()
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 pywrangler.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 pywrangler.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
- from pywrangler.utils import (
15
- run_command,
11
+ from .utils import (
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 pywrangler.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"wrangler\s+(\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,7 +171,7 @@ 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},
@@ -354,21 +186,21 @@ 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
+ env=os.environ | {"VIRTUAL_ENV": get_pyodide_venv_path()},
362
194
  )
363
- pyv = _get_python_version()
195
+ pyv = get_python_version()
364
196
  shutil.rmtree(vendor_path)
365
197
  shutil.copytree(
366
- PYODIDE_VENV_PATH / f"lib/python{pyv}/site-packages", vendor_path
198
+ get_pyodide_venv_path() / f"lib/python{pyv}/site-packages", vendor_path
367
199
  )
368
200
 
369
201
  # Create a pyvenv.cfg file in python_modules to mark it as a virtual environment
370
202
  (vendor_path / "pyvenv.cfg").touch()
371
- VENDOR_TOKEN.touch()
203
+ get_vendor_token_path().touch()
372
204
 
373
205
  logger.info(
374
206
  f"Packages installed in [bold]{relative_vendor_path}[/bold].",
@@ -376,9 +208,11 @@ def _install_requirements_to_vendor(requirements: list[str]):
376
208
  )
377
209
 
378
210
 
379
- def _install_requirements_to_venv(requirements: list[str]):
211
+ def _install_requirements_to_venv(requirements: list[str]) -> None:
380
212
  # Create a requirements file for .venv-workers that includes pyodide-py
381
- relative_venv_workers_path = VENV_WORKERS_PATH.relative_to(PROJECT_ROOT)
213
+ venv_workers_path = get_venv_workers_path()
214
+ project_root = get_project_root()
215
+ relative_venv_workers_path = venv_workers_path.relative_to(project_root)
382
216
  requirements = requirements.copy()
383
217
  requirements.append("pyodide-py")
384
218
 
@@ -395,16 +229,17 @@ def _install_requirements_to_venv(requirements: list[str]):
395
229
  "-r",
396
230
  requirements_file,
397
231
  ],
398
- env=os.environ | {"VIRTUAL_ENV": VENV_WORKERS_PATH},
232
+ env=os.environ | {"VIRTUAL_ENV": venv_workers_path},
399
233
  )
400
- VENV_WORKERS_TOKEN.touch()
234
+
235
+ get_venv_workers_token_path().touch()
401
236
  logger.info(
402
237
  f"Packages installed in [bold]{relative_venv_workers_path}[/bold].",
403
238
  extra={"markup": True},
404
239
  )
405
240
 
406
241
 
407
- def install_requirements(requirements: list[str]):
242
+ def install_requirements(requirements: list[str]) -> None:
408
243
  _install_requirements_to_vendor(requirements)
409
244
  _install_requirements_to_venv(requirements)
410
245
 
@@ -415,19 +250,50 @@ def _is_out_of_date(token: Path, time: float) -> bool:
415
250
  return time > token.stat().st_mtime
416
251
 
417
252
 
418
- def is_sync_needed():
253
+ def is_sync_needed() -> bool:
419
254
  """
420
255
  Checks if pyproject.toml has been modified since the last sync.
421
256
 
422
257
  Returns:
423
258
  bool: True if sync is needed, False otherwise
424
259
  """
425
-
426
- if not PYPROJECT_TOML_PATH.is_file():
260
+ pyproject_toml_path = find_pyproject_toml()
261
+ if not pyproject_toml_path.is_file():
427
262
  # If pyproject.toml doesn't exist, we need to abort anyway
428
263
  return True
429
264
 
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
265
+ pyproject_mtime = pyproject_toml_path.stat().st_mtime
266
+ return _is_out_of_date(get_vendor_token_path(), pyproject_mtime) or _is_out_of_date(
267
+ get_venv_workers_token_path(), pyproject_mtime
433
268
  )
269
+
270
+
271
+ def sync(force: bool = False, directly_requested: bool = False) -> None:
272
+ # Check if requirements.txt does not exist.
273
+ check_requirements_txt()
274
+
275
+ # Check if sync is needed based on file timestamps
276
+ sync_needed = force or is_sync_needed()
277
+ if not sync_needed:
278
+ if directly_requested:
279
+ logger.warning(
280
+ "pyproject.toml hasn't changed since last sync, use --force to ignore timestamp check"
281
+ )
282
+ return
283
+
284
+ # Check to make sure a wrangler config file exists.
285
+ check_wrangler_config()
286
+
287
+ # Create .venv-workers if it doesn't exist
288
+ create_workers_venv()
289
+
290
+ # Set up Pyodide virtual env
291
+ create_pyodide_venv()
292
+
293
+ # Generate requirements.txt from pyproject.toml by directly parsing the TOML file then install into vendor folder.
294
+ requirements = parse_requirements()
295
+ if not requirements:
296
+ logger.warning(
297
+ "No dependencies found in [project.dependencies] section of pyproject.toml."
298
+ )
299
+ 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
 
@@ -85,12 +94,13 @@ def run_command(
85
94
  logger.error(
86
95
  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 ''}"
87
96
  )
88
- raise click.exceptions.Exit(code=e.returncode)
97
+ raise click.exceptions.Exit(code=e.returncode) from None
89
98
  except FileNotFoundError:
90
99
  logger.error(f"Command not found: {command[0]}. Is it installed and in PATH?")
91
- raise click.exceptions.Exit(code=1)
100
+ raise click.exceptions.Exit(code=1) from None
92
101
 
93
102
 
103
+ @cache
94
104
  def find_pyproject_toml() -> Path:
95
105
  """
96
106
  Search for pyproject.toml starting from current working directory and going up the directory tree.
@@ -112,3 +122,200 @@ def find_pyproject_toml() -> Path:
112
122
  f"pyproject.toml not found in {Path.cwd().resolve()} or any parent directories"
113
123
  )
114
124
  raise click.exceptions.Exit(code=1)
125
+
126
+
127
+ class PyProjectProject(TypedDict):
128
+ dependencies: list[str]
129
+
130
+
131
+ class PyProject(TypedDict):
132
+ project: PyProjectProject
133
+
134
+
135
+ def read_pyproject_toml() -> PyProject:
136
+ pyproject_toml = find_pyproject_toml()
137
+ logger.debug(f"Reading {pyproject_toml}...")
138
+ try:
139
+ with open(pyproject_toml, "rb") as f:
140
+ return cast(PyProject, tomllib.load(f))
141
+ except tomllib.TOMLDecodeError as e:
142
+ logger.error(f"Error parsing {pyproject_toml}: {str(e)}")
143
+ raise click.exceptions.Exit(code=1) from None
144
+
145
+
146
+ def get_project_root() -> Path:
147
+ return find_pyproject_toml().parent
148
+
149
+
150
+ MIN_UV_VERSION = (0, 8, 10)
151
+ MIN_WRANGLER_VERSION = (4, 42, 1)
152
+
153
+
154
+ def check_uv_version() -> None:
155
+ res = run_command(["uv", "--version"], capture_output=True)
156
+ ver_str = res.stdout.split(" ")[1]
157
+ ver = tuple(int(x) for x in ver_str.split("."))
158
+ if ver >= MIN_UV_VERSION:
159
+ return
160
+ min_version_str = ".".join(str(x) for x in MIN_UV_VERSION)
161
+ logger.error(f"uv version at least {min_version_str} required, have {ver_str}.")
162
+ logger.error("Update uv with `uv self update`.")
163
+ raise click.exceptions.Exit(code=1)
164
+
165
+
166
+ def check_wrangler_version() -> None:
167
+ """
168
+ Check that the installed wrangler version is at least 4.42.1.
169
+
170
+ Raises:
171
+ click.exceptions.Exit: If wrangler is not installed or version is too old.
172
+ """
173
+ result = run_command(
174
+ ["npx", "--yes", "wrangler", "--version"], capture_output=True, check=False
175
+ )
176
+ if result.returncode != 0:
177
+ logger.error("Failed to get wrangler version. Is wrangler installed?")
178
+ logger.error("Install wrangler with: npm install wrangler@latest")
179
+ raise click.exceptions.Exit(code=1)
180
+
181
+ # Parse version from output like "wrangler 4.42.1" or " ⛅️ wrangler 4.42.1"
182
+ version_line = result.stdout.strip()
183
+ # Extract version number using regex
184
+ version_match = re.search(r"(\d+)\.(\d+)\.(\d+)", version_line)
185
+
186
+ if not version_match:
187
+ logger.error(f"Could not parse wrangler version from: {version_line}")
188
+ logger.error("Install wrangler with: npm install wrangler@latest")
189
+ raise click.exceptions.Exit(code=1)
190
+
191
+ major, minor, patch = map(int, version_match.groups())
192
+ current_version = (major, minor, patch)
193
+
194
+ if current_version < MIN_WRANGLER_VERSION:
195
+ min_version_str = ".".join(str(x) for x in MIN_WRANGLER_VERSION)
196
+ current_version_str = ".".join(str(x) for x in current_version)
197
+ logger.error(
198
+ f"wrangler version at least {min_version_str} required, have {current_version_str}."
199
+ )
200
+ logger.error("Update wrangler with: npm install wrangler@latest")
201
+ raise click.exceptions.Exit(code=1)
202
+
203
+ logger.debug(
204
+ f"wrangler version {'.'.join(str(x) for x in current_version)} is sufficient"
205
+ )
206
+
207
+
208
+ def check_wrangler_config() -> None:
209
+ PROJECT_ROOT = get_project_root()
210
+ wrangler_jsonc = PROJECT_ROOT / "wrangler.jsonc"
211
+ wrangler_toml = PROJECT_ROOT / "wrangler.toml"
212
+ if not wrangler_jsonc.is_file() and not wrangler_toml.is_file():
213
+ logger.error(
214
+ f"{wrangler_jsonc} or {wrangler_toml} not found in {PROJECT_ROOT}."
215
+ )
216
+ raise click.exceptions.Exit(code=1)
217
+
218
+
219
+ class WranglerConfig(TypedDict, total=False):
220
+ compatibility_date: str
221
+ compatibility_flags: list[str]
222
+
223
+
224
+ def _parse_wrangler_config() -> WranglerConfig:
225
+ """
226
+ Parse wrangler configuration from either wrangler.toml or wrangler.jsonc.
227
+
228
+ Returns:
229
+ dict: Parsed configuration data
230
+ """
231
+ PROJECT_ROOT = get_project_root()
232
+ wrangler_toml = PROJECT_ROOT / "wrangler.toml"
233
+ wrangler_jsonc = PROJECT_ROOT / "wrangler.jsonc"
234
+
235
+ if wrangler_toml.is_file():
236
+ try:
237
+ with open(wrangler_toml, "rb") as f:
238
+ return cast(WranglerConfig, tomllib.load(f))
239
+ except tomllib.TOMLDecodeError as e:
240
+ logger.error(f"Error parsing {wrangler_toml}: {e}")
241
+ raise click.exceptions.Exit(code=1) from None
242
+
243
+ if wrangler_jsonc.is_file():
244
+ try:
245
+ with open(wrangler_jsonc) as f:
246
+ content = f.read()
247
+ return cast(WranglerConfig, pyjson5.loads(content))
248
+ except (pyjson5.Json5DecoderException, ValueError) as e:
249
+ logger.error(f"Error parsing {wrangler_jsonc}: {e}")
250
+ raise click.exceptions.Exit(code=1) from None
251
+
252
+ return {}
253
+
254
+
255
+ @cache
256
+ def get_python_version() -> Literal["3.12", "3.13"]:
257
+ """
258
+ Determine Python version from wrangler configuration.
259
+
260
+ Returns:
261
+ Python version string
262
+ """
263
+ config = _parse_wrangler_config()
264
+
265
+ if not config:
266
+ logger.error("No wrangler config found")
267
+ raise click.exceptions.Exit(code=1)
268
+
269
+ compat_flags = config.get("compatibility_flags", [])
270
+ compat_date_str = config.get("compatibility_date", None)
271
+ if compat_date_str is None:
272
+ logger.error("No compatibility_date specified in wrangler config")
273
+ raise click.exceptions.Exit(code=1)
274
+ try:
275
+ compat_date = datetime.strptime(compat_date_str, "%Y-%m-%d")
276
+ except ValueError:
277
+ logger.error(
278
+ f"Invalid compatibility_date format: {config.get('compatibility_date')}"
279
+ )
280
+ raise click.exceptions.Exit(code=1) from None
281
+
282
+ # Check if python_workers base flag is present (required for Python workers)
283
+ if "python_workers" not in compat_flags:
284
+ logger.error("`python_workers` compat flag not specified in wrangler config")
285
+ raise click.exceptions.Exit(code=1)
286
+
287
+ # Find the most specific Python version based on compat flags and date
288
+ # Sort by version descending to prioritize newer versions
289
+ sorted_versions = sorted(
290
+ PYTHON_COMPAT_VERSIONS, key=lambda x: x.version, reverse=True
291
+ )
292
+
293
+ for py_version in sorted_versions:
294
+ # Check if the specific compat flag is present
295
+ if py_version.compat_flag in compat_flags:
296
+ return py_version.version
297
+
298
+ # For versions with compat_date, also check the date requirement
299
+ if py_version.compat_date and compat_date >= py_version.compat_date:
300
+ return py_version.version
301
+
302
+ logger.error("Could not determine Python version from wrangler config")
303
+ raise click.exceptions.Exit(code=1)
304
+
305
+
306
+ def get_uv_pyodide_interp_name() -> str:
307
+ match get_python_version():
308
+ case "3.12":
309
+ v = "3.12.7"
310
+ case "3.13":
311
+ v = "3.13.2"
312
+ return f"cpython-{v}-emscripten-wasm32-musl"
313
+
314
+
315
+ def get_pyodide_index() -> str:
316
+ match get_python_version():
317
+ case "3.12":
318
+ v = "0.27.7"
319
+ case "3.13":
320
+ v = "0.28.3"
321
+ 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.0
3
+ Version: 1.6.2
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=zn3396_8Hotu_6FnlQwdITgdUxze50tbO5xVzwjUxIU,9498
7
+ pywrangler/types.py,sha256=hYJ6hNIjWb0faBGW82AWfCBsF_JPb7sXKCXPtFM3Mhk,1183
8
+ pywrangler/utils.py,sha256=AHF-0VMdgQPVFkZfanHEQNyoNpAFbOstaXDNbNb2T5Y,10368
9
+ workers_py-1.6.2.dist-info/METADATA,sha256=yXqeNFXPfH92_6v7dtFKw8fYExJjYtsbpXOw2pQb5OY,1774
10
+ workers_py-1.6.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ workers_py-1.6.2.dist-info/entry_points.txt,sha256=pt6X-Nv5-gSiKUwrnvLwzlSXs9yP37m7zdTAi8f6nAM,50
12
+ workers_py-1.6.2.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- pywrangler/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- pywrangler/__main__.py,sha256=BnrUM7YiBmlM4HAn2MI9hP1kVNtzeK_kEgQhRy5HTq0,38
3
- pywrangler/cli.py,sha256=MBY2S5Bx4D-CxmNm5WT5ln0ch-KrDfahUer4b031GZM,6616
4
- pywrangler/metadata.py,sha256=vttmaCtouSr9ADj8ncvNGqeaWEGFP8pamH2T6ohFjnA,408
5
- pywrangler/sync.py,sha256=CAJdZRZb_xGBX0c4pskfJFrKt8cq6wBpDtPbrWnVJeQ,14248
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.0.dist-info/METADATA,sha256=3Y3CB4to-UbKMPT8fMK26JkI3lQslRKrKuqnNwLS0hs,1732
9
- workers_py-1.6.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
- workers_py-1.6.0.dist-info/entry_points.txt,sha256=pt6X-Nv5-gSiKUwrnvLwzlSXs9yP37m7zdTAi8f6nAM,50
11
- workers_py-1.6.0.dist-info/RECORD,,