Skip to content

Commit 149547d

Browse files
hukkinofek
andauthored
Create binary wheels with mypyc (#242)
Co-authored-by: Ofek Lev <[email protected]>
1 parent 443a0c1 commit 149547d

File tree

11 files changed

+219
-39
lines changed

11 files changed

+219
-39
lines changed

.github/workflows/tests.yaml

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ on:
77
pull_request:
88
branches: [ master ]
99

10+
env:
11+
CIBW_TEST_COMMAND: python -m unittest discover --start-directory {project}
12+
CIBW_SKIP: pp*
13+
CIBW_ENVIRONMENT_PASS_LINUX: TOMLI_USE_MYPYC
14+
1015
jobs:
1116

1217
linters:
@@ -74,12 +79,102 @@ jobs:
7479
with:
7580
token: ${{ secrets.CODECOV_TOKEN }}
7681

82+
binary-wheels-standard:
83+
name: Binary wheels for ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }}
84+
runs-on: ${{ matrix.os }}
85+
strategy:
86+
matrix:
87+
os: [ubuntu-latest, windows-latest, macos-latest]
88+
89+
steps:
90+
- uses: actions/checkout@v4
91+
92+
- uses: actions/setup-python@v5
93+
with:
94+
python-version: '3.x'
95+
96+
- name: Switch build backend to setuptools
97+
run: |
98+
pip install -r scripts/requirements.txt
99+
python scripts/use_setuptools.py
100+
101+
- name: Build wheels
102+
uses: pypa/[email protected]
103+
env:
104+
CIBW_ARCHS_MACOS: x86_64 arm64
105+
TOMLI_USE_MYPYC: '1'
106+
107+
- uses: actions/upload-artifact@v4
108+
with:
109+
name: artifact-standard-${{ matrix.os }}
110+
path: wheelhouse/*.whl
111+
if-no-files-found: error
112+
113+
pure-python-wheel-and-sdist:
114+
name: Build a pure Python wheel and source distribution
115+
runs-on: ubuntu-latest
116+
117+
steps:
118+
- uses: actions/checkout@v4
119+
120+
- name: Install build dependencies
121+
run: pip install build
122+
123+
- name: Build
124+
run: python -m build
125+
126+
- uses: actions/upload-artifact@v4
127+
with:
128+
name: artifact-pure-python
129+
path: dist/*
130+
if-no-files-found: error
131+
132+
binary-wheels-arm:
133+
name: Build Linux wheels for ARM
134+
runs-on: ubuntu-latest
135+
# Very slow (~ 1 hour), no need to run on PRs
136+
if: >
137+
github.event_name == 'push'
138+
&&
139+
(github.ref == 'refs/heads/master' || startsWith(github.event.ref, 'refs/tags'))
140+
steps:
141+
- uses: actions/checkout@v4
142+
143+
- uses: actions/setup-python@v5
144+
with:
145+
python-version: '3.x'
146+
147+
- name: Switch build backend to setuptools
148+
run: |
149+
pip install -r scripts/requirements.txt
150+
python scripts/use_setuptools.py
151+
152+
- name: Set up QEMU
153+
uses: docker/setup-qemu-action@v3
154+
with:
155+
platforms: arm64
156+
157+
- name: Build wheels
158+
uses: pypa/[email protected]
159+
env:
160+
CIBW_ARCHS_LINUX: aarch64
161+
TOMLI_USE_MYPYC: '1'
162+
163+
- uses: actions/upload-artifact@v4
164+
with:
165+
name: artifact-arm-linux
166+
path: wheelhouse/*.whl
167+
if-no-files-found: error
168+
77169
allgood:
78170
runs-on: ubuntu-latest
79171
needs:
80172
- tests
81173
- coverage
82174
- linters
175+
- binary-wheels-standard
176+
- pure-python-wheel-and-sdist
177+
- binary-wheels-arm
83178
steps:
84179
- run: echo "Great success!"
85180

@@ -89,19 +184,20 @@ jobs:
89184
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags')
90185
runs-on: ubuntu-latest
91186
steps:
92-
- uses: actions/checkout@v4
187+
- uses: actions/download-artifact@v4
188+
with:
189+
path: dist
190+
pattern: artifact-*
191+
merge-multiple: true
93192
- uses: actions/setup-python@v5
94193
with:
95194
python-version: '3.x'
96-
- name: Install build and publish tools
195+
- name: Install twine
97196
run: |
98-
pip install build twine
99-
- name: Build and check
197+
pip install twine
198+
- name: Check and publish
100199
run: |
101-
rm -rf dist/ && python -m build
102200
twine check --strict dist/*
103-
- name: Publish
104-
run: |
105201
twine upload dist/*
106202
env:
107203
TWINE_USERNAME: __token__

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 2.2.0
4+
5+
- Added
6+
- mypyc generated binary wheels for common platforms
7+
38
## 2.1.0
49

510
- Deprecated

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
prune tests/

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ env_list = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
5050

5151
[tool.tox.env_run_base]
5252
description = "run tests against a built package under {base_python}"
53+
pass_env = ["TOMLI_USE_MYPYC"]
5354
commands = [
5455
["python", "-m", "unittest", { replace = "posargs", extend = true }],
5556
]

scripts/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
tomli-w

scripts/use_setuptools.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from pathlib import Path
2+
import tomllib
3+
4+
import tomli_w # type: ignore[import-not-found]
5+
6+
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
7+
data = tomllib.loads(pyproject_path.read_bytes().decode())
8+
data["build-system"] = {
9+
"requires": ["setuptools>=69", "mypy[mypyc]>=1.13"],
10+
"build-backend": "setuptools.build_meta",
11+
}
12+
pyproject_path.write_bytes(tomli_w.dumps(data).encode())

setup.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import os
2+
3+
from setuptools import setup # type: ignore[import-untyped]
4+
5+
if os.environ.get("TOMLI_USE_MYPYC") == "1":
6+
import glob
7+
8+
from mypyc.build import mypycify # type: ignore[import-untyped]
9+
10+
files = glob.glob("src/**/*.py", recursive=True)
11+
ext_modules = mypycify(files)
12+
else:
13+
ext_modules = []
14+
15+
setup(ext_modules=ext_modules)

src/tomli/__init__.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,3 @@
66
__version__ = "2.1.0" # DO NOT EDIT THIS LINE MANUALLY. LET bump2version UTILITY DO IT
77

88
from ._parser import TOMLDecodeError, load, loads
9-
10-
# Pretend this exception was created here.
11-
TOMLDecodeError.__module__ = __name__

src/tomli/_parser.py

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66

77
from collections.abc import Iterable
88
import string
9+
import sys
910
from types import MappingProxyType
10-
from typing import IO, Any, NamedTuple
11+
from typing import IO, Any, Final, NamedTuple
1112
import warnings
1213

1314
from ._re import (
@@ -20,6 +21,17 @@
2021
)
2122
from ._types import Key, ParseFloat, Pos
2223

24+
# Inline tables/arrays are implemented using recursion. Pathologically
25+
# nested documents cause pure Python to raise RecursionError (which is OK),
26+
# but mypyc binary wheels will crash unrecoverably (not OK). According to
27+
# mypyc docs this will be fixed in the future:
28+
# https://mypyc.readthedocs.io/en/latest/differences_from_python.html#stack-overflows
29+
# Before mypyc's fix is in, recursion needs to be limited by this library.
30+
# Choosing `sys.getrecursionlimit()` as maximum inline table/array nesting
31+
# level, as it allows more nesting than pure Python, but still seems a far
32+
# lower number than where mypyc binaries crash.
33+
MAX_INLINE_NESTING: Final = sys.getrecursionlimit()
34+
2335
ASCII_CTRL = frozenset(chr(i) for i in range(32)) | frozenset(chr(127))
2436

2537
# Neither of these sets include quotation mark or backslash. They are
@@ -69,9 +81,9 @@ class TOMLDecodeError(ValueError):
6981

7082
def __init__(
7183
self,
72-
msg: str = DEPRECATED_DEFAULT, # type: ignore[assignment]
73-
doc: str = DEPRECATED_DEFAULT, # type: ignore[assignment]
74-
pos: Pos = DEPRECATED_DEFAULT, # type: ignore[assignment]
84+
msg: str | type[DEPRECATED_DEFAULT] = DEPRECATED_DEFAULT,
85+
doc: str | type[DEPRECATED_DEFAULT] = DEPRECATED_DEFAULT,
86+
pos: Pos | type[DEPRECATED_DEFAULT] = DEPRECATED_DEFAULT,
7587
*args: Any,
7688
):
7789
if (
@@ -86,11 +98,11 @@ def __init__(
8698
DeprecationWarning,
8799
stacklevel=2,
88100
)
89-
if pos is not DEPRECATED_DEFAULT: # type: ignore[comparison-overlap]
101+
if pos is not DEPRECATED_DEFAULT:
90102
args = pos, *args
91-
if doc is not DEPRECATED_DEFAULT: # type: ignore[comparison-overlap]
103+
if doc is not DEPRECATED_DEFAULT:
92104
args = doc, *args
93-
if msg is not DEPRECATED_DEFAULT: # type: ignore[comparison-overlap]
105+
if msg is not DEPRECATED_DEFAULT:
94106
args = msg, *args
95107
ValueError.__init__(self, *args)
96108
return
@@ -202,10 +214,10 @@ class Flags:
202214
"""Flags that map to parsed keys/namespaces."""
203215

204216
# Marks an immutable namespace (inline array or inline table).
205-
FROZEN = 0
217+
FROZEN: Final = 0
206218
# Marks a nest that has been explicitly created and can no longer
207219
# be opened using the "[table]" syntax.
208-
EXPLICIT_NEST = 1
220+
EXPLICIT_NEST: Final = 1
209221

210222
def __init__(self) -> None:
211223
self._flags: dict[str, dict] = {}
@@ -251,8 +263,8 @@ def is_(self, key: Key, flag: int) -> bool:
251263
cont = inner_cont["nested"]
252264
key_stem = key[-1]
253265
if key_stem in cont:
254-
cont = cont[key_stem]
255-
return flag in cont["flags"] or flag in cont["recursive_flags"]
266+
inner_cont = cont[key_stem]
267+
return flag in inner_cont["flags"] or flag in inner_cont["recursive_flags"]
256268
return False
257269

258270

@@ -393,7 +405,7 @@ def create_list_rule(src: str, pos: Pos, out: Output) -> tuple[Pos, Key]:
393405
def key_value_rule(
394406
src: str, pos: Pos, out: Output, header: Key, parse_float: ParseFloat
395407
) -> Pos:
396-
pos, key, value = parse_key_value_pair(src, pos, parse_float)
408+
pos, key, value = parse_key_value_pair(src, pos, parse_float, nest_lvl=0)
397409
key_parent, key_stem = key[:-1], key[-1]
398410
abs_key_parent = header + key_parent
399411

@@ -425,7 +437,7 @@ def key_value_rule(
425437

426438

427439
def parse_key_value_pair(
428-
src: str, pos: Pos, parse_float: ParseFloat
440+
src: str, pos: Pos, parse_float: ParseFloat, nest_lvl: int
429441
) -> tuple[Pos, Key, Any]:
430442
pos, key = parse_key(src, pos)
431443
try:
@@ -436,7 +448,7 @@ def parse_key_value_pair(
436448
raise TOMLDecodeError("Expected '=' after a key in a key/value pair", src, pos)
437449
pos += 1
438450
pos = skip_chars(src, pos, TOML_WS)
439-
pos, value = parse_value(src, pos, parse_float)
451+
pos, value = parse_value(src, pos, parse_float, nest_lvl)
440452
return pos, key, value
441453

442454

@@ -479,15 +491,17 @@ def parse_one_line_basic_str(src: str, pos: Pos) -> tuple[Pos, str]:
479491
return parse_basic_str(src, pos, multiline=False)
480492

481493

482-
def parse_array(src: str, pos: Pos, parse_float: ParseFloat) -> tuple[Pos, list]:
494+
def parse_array(
495+
src: str, pos: Pos, parse_float: ParseFloat, nest_lvl: int
496+
) -> tuple[Pos, list]:
483497
pos += 1
484498
array: list = []
485499

486500
pos = skip_comments_and_array_ws(src, pos)
487501
if src.startswith("]", pos):
488502
return pos + 1, array
489503
while True:
490-
pos, val = parse_value(src, pos, parse_float)
504+
pos, val = parse_value(src, pos, parse_float, nest_lvl)
491505
array.append(val)
492506
pos = skip_comments_and_array_ws(src, pos)
493507

@@ -503,7 +517,9 @@ def parse_array(src: str, pos: Pos, parse_float: ParseFloat) -> tuple[Pos, list]
503517
return pos + 1, array
504518

505519

506-
def parse_inline_table(src: str, pos: Pos, parse_float: ParseFloat) -> tuple[Pos, dict]:
520+
def parse_inline_table(
521+
src: str, pos: Pos, parse_float: ParseFloat, nest_lvl: int
522+
) -> tuple[Pos, dict]:
507523
pos += 1
508524
nested_dict = NestedDict()
509525
flags = Flags()
@@ -512,7 +528,7 @@ def parse_inline_table(src: str, pos: Pos, parse_float: ParseFloat) -> tuple[Pos
512528
if src.startswith("}", pos):
513529
return pos + 1, nested_dict.dict
514530
while True:
515-
pos, key, value = parse_key_value_pair(src, pos, parse_float)
531+
pos, key, value = parse_key_value_pair(src, pos, parse_float, nest_lvl)
516532
key_parent, key_stem = key[:-1], key[-1]
517533
if flags.is_(key, Flags.FROZEN):
518534
raise TOMLDecodeError(f"Cannot mutate immutable namespace {key}", src, pos)
@@ -654,8 +670,16 @@ def parse_basic_str(src: str, pos: Pos, *, multiline: bool) -> tuple[Pos, str]:
654670

655671

656672
def parse_value( # noqa: C901
657-
src: str, pos: Pos, parse_float: ParseFloat
673+
src: str, pos: Pos, parse_float: ParseFloat, nest_lvl: int
658674
) -> tuple[Pos, Any]:
675+
if nest_lvl > MAX_INLINE_NESTING:
676+
# Pure Python should have raised RecursionError already.
677+
# This ensures mypyc binaries eventually do the same.
678+
raise RecursionError( # pragma: no cover
679+
"TOML inline arrays/tables are nested more than the allowed"
680+
f" {MAX_INLINE_NESTING} levels"
681+
)
682+
659683
try:
660684
char: str | None = src[pos]
661685
except IndexError:
@@ -685,11 +709,11 @@ def parse_value( # noqa: C901
685709

686710
# Arrays
687711
if char == "[":
688-
return parse_array(src, pos, parse_float)
712+
return parse_array(src, pos, parse_float, nest_lvl + 1)
689713

690714
# Inline tables
691715
if char == "{":
692-
return parse_inline_table(src, pos, parse_float)
716+
return parse_inline_table(src, pos, parse_float, nest_lvl + 1)
693717

694718
# Dates and times
695719
datetime_match = RE_DATETIME.match(src, pos)

0 commit comments

Comments
 (0)