Skip to content

Commit 0cdcf9f

Browse files
Merge pull request rawpython#553 from marcelo-srs/master
Python 3.13/3.14 compatibility: fix escape sequence and cgi removal
2 parents 835a17e + 3cd755f commit 0cdcf9f

4 files changed

Lines changed: 183 additions & 31 deletions

File tree

remi/__init__.py

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -32,32 +32,26 @@
3232

3333
from .server import App, Server, start
3434

35-
# importlib.metadata is available in Python 3.8+
36-
useForVersionCheck = None
37-
try:
38-
import importlib.metadata
39-
useForVersionCheck = "importlib.metadata"
40-
except ImportError:
41-
try:
42-
import pkg_resources
43-
useForVersionCheck = "pkg_resources"
44-
except ImportError:
45-
pass
35+
# Hardcoded fallback version — used when package metadata is unavailable
36+
# (e.g. PyInstaller frozen builds, editable installs without metadata).
37+
# Keep in sync with setup.py.
38+
_FALLBACK_VERSION = "2026.03.24"
39+
__version__ = _FALLBACK_VERSION
4640

47-
if useForVersionCheck == "importlib.metadata":
41+
# importlib.metadata is available in Python 3.8+; pkg_resources for older.
42+
try:
4843
from importlib.metadata import version, PackageNotFoundError
4944
try:
5045
__version__ = version(__name__)
5146
except PackageNotFoundError:
52-
# package is not installed
53-
pass
54-
elif useForVersionCheck == "pkg_resources":
55-
from pkg_resources import get_distribution, DistributionNotFound
47+
pass # metadata absent (frozen build etc.) — fallback stays
48+
except ImportError:
5649
try:
57-
__version__ = get_distribution(__name__).version
58-
except DistributionNotFound:
59-
# package is not installed
60-
pass
61-
else:
62-
# neither importlib.metadata nor pkg_resources is available
63-
print("WARNING: cannot check remi version, please install importlib-metadata (python >= 3.8) or the pkg_resources module by installing setuptools")
50+
from pkg_resources import get_distribution, DistributionNotFound
51+
try:
52+
__version__ = get_distribution(__name__).version
53+
except DistributionNotFound:
54+
pass # package not installed — fallback stays
55+
except ImportError:
56+
print("WARNING: cannot check remi version, please install "
57+
"importlib-metadata (Python >= 3.8) or setuptools")

remi/server.py

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,47 @@
4747
from urllib.parse import unquote_to_bytes
4848
from urllib.parse import urlparse
4949
from urllib.parse import parse_qs
50-
import cgi
50+
import io
5151
import weakref
5252

53+
# cgi.FieldStorage was removed in Python 3.13 (deprecated since 3.11).
54+
# Provide a minimal replacement using email.parser when cgi is absent.
55+
try:
56+
from cgi import FieldStorage as _FieldStorage
57+
_HAS_CGI = True
58+
except ImportError:
59+
import email.parser as _email_parser
60+
_HAS_CGI = False
61+
62+
class _FieldItem:
63+
"""Minimal stand-in for a cgi.FieldStorage field (file upload part)."""
64+
def __init__(self, filename, data):
65+
self.filename = filename
66+
self.file = io.BytesIO(data if data is not None else b'')
67+
68+
class _FieldStorage:
69+
"""Minimal stand-in for cgi.FieldStorage, used only in do_POST (file uploads)."""
70+
def __init__(self, fp, headers, environ):
71+
self._fields = {}
72+
content_length = int(headers.get('Content-Length', 0))
73+
body = fp.read(content_length)
74+
content_type = environ.get('CONTENT_TYPE', headers.get('Content-Type', ''))
75+
raw = ('Content-Type: %s\r\n\r\n' % content_type).encode('utf-8') + body
76+
msg = _email_parser.BytesParser().parsebytes(raw)
77+
if msg.is_multipart():
78+
for part in msg.get_payload():
79+
name = part.get_param('name', header='content-disposition')
80+
filename = part.get_param('filename', header='content-disposition')
81+
if name:
82+
data = part.get_payload(decode=True)
83+
self._fields[name] = _FieldItem(filename, data)
84+
85+
def keys(self):
86+
return self._fields.keys()
87+
88+
def __getitem__(self, key):
89+
return self._fields[key]
90+
5391
import zlib
5492

5593
import select
@@ -574,10 +612,10 @@ def do_POST(self):
574612
filename = self.headers['filename']
575613
listener_widget = runtimeInstances[self.headers['listener']]
576614
listener_function = self.headers['listener_function']
577-
form = cgi.FieldStorage(fp=self.rfile,
578-
headers=self.headers,
579-
environ={'REQUEST_METHOD': 'POST',
580-
'CONTENT_TYPE': self.headers['Content-Type']})
615+
form = _FieldStorage(fp=self.rfile,
616+
headers=self.headers,
617+
environ={'REQUEST_METHOD': 'POST',
618+
'CONTENT_TYPE': self.headers['Content-Type']})
581619
# Echo back information about what was posted in the form
582620
for field in form.keys():
583621
field_item = form[field]
@@ -762,8 +800,8 @@ def onload(self, emitter):
762800
def onerror(self, message, source, lineno, colno, error):
763801
""" WebPage Event that occurs on webpage errors
764802
"""
765-
self._log.debug("""App.onerror event occurred in webpage:
766-
\nMESSAGE:%s\nSOURCE:%s\nLINENO:%s\nCOLNO:%s\ERROR:%s\n"""%(message, source, lineno, colno, error))
803+
self._log.debug("""App.onerror event occurred in webpage:
804+
\nMESSAGE:%s\nSOURCE:%s\nLINENO:%s\nCOLNO:%s\\ERROR:%s\n"""%(message, source, lineno, colno, error))
767805

768806
def ononline(self, emitter):
769807
""" WebPage Event that occurs on webpage goes online after a disconnection

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
'packages':setuptools.find_packages(),
2222
'include_package_data':True,
2323
'setup_requires':['setuptools_scm'],
24-
'version': '2026.02.04',
24+
'version': '2026.03.24',
2525
}
2626
try:
2727
setup(**params)

test/test_version.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env python
2+
"""
3+
Tests for Python 3.13 / PyInstaller compatibility:
4+
- __version__ is always defined (even when package metadata is absent)
5+
- importlib.metadata path is used on Python 3.8+
6+
- pkg_resources fallback is used when importlib.metadata is unavailable
7+
- Hardcoded fallback is used when neither can find the package
8+
- server.py contains no invalid escape sequences (SyntaxWarning-free)
9+
"""
10+
11+
import importlib
12+
import py_compile
13+
import os
14+
import sys
15+
import unittest
16+
import warnings
17+
from unittest.mock import patch, MagicMock
18+
19+
import remi
20+
21+
# Absolute path to server.py so tests run from any working directory
22+
_SERVER_PY = os.path.join(os.path.dirname(__file__), '..', 'remi', 'server.py')
23+
_SERVER_PY = os.path.normpath(_SERVER_PY)
24+
25+
26+
class TestNoSyntaxWarnings(unittest.TestCase):
27+
"""server.py must compile without any SyntaxWarning (e.g. invalid escape sequences)."""
28+
29+
def test_server_py_no_syntax_warnings(self):
30+
"""Compiling server.py raises no SyntaxWarning on Python 3.12+."""
31+
with warnings.catch_warnings():
32+
warnings.simplefilter("error", SyntaxWarning)
33+
try:
34+
py_compile.compile(_SERVER_PY, doraise=True)
35+
except py_compile.PyCompileError as exc:
36+
self.fail("server.py has a SyntaxWarning/SyntaxError: %s" % exc)
37+
38+
39+
class TestVersionAlwaysDefined(unittest.TestCase):
40+
"""remi.__version__ must always be a non-empty string."""
41+
42+
def test_version_is_string(self):
43+
self.assertIsInstance(remi.__version__, str)
44+
45+
def test_version_non_empty(self):
46+
self.assertTrue(len(remi.__version__) > 0)
47+
48+
49+
class TestVersionImportlibMetadataPath(unittest.TestCase):
50+
"""When importlib.metadata.version() succeeds, __version__ is taken from it."""
51+
52+
def test_importlib_metadata_used(self):
53+
fake_version = "9999.01.01"
54+
with patch("importlib.metadata.version", return_value=fake_version):
55+
importlib.reload(remi)
56+
self.assertEqual(remi.__version__, fake_version)
57+
# Restore
58+
importlib.reload(remi)
59+
60+
61+
class TestVersionPkgResourcesFallback(unittest.TestCase):
62+
"""When importlib.metadata raises ImportError, pkg_resources provides the version."""
63+
64+
def test_pkg_resources_fallback(self):
65+
fake_version = "8888.06.15"
66+
67+
mock_dist = MagicMock()
68+
mock_dist.version = fake_version
69+
mock_pkg = MagicMock()
70+
mock_pkg.get_distribution.return_value = mock_dist
71+
mock_pkg.DistributionNotFound = Exception
72+
73+
# Make importlib.metadata unavailable, supply a mock pkg_resources
74+
with patch.dict(sys.modules, {"importlib.metadata": None, "pkg_resources": mock_pkg}):
75+
importlib.reload(remi)
76+
77+
self.assertEqual(remi.__version__, fake_version)
78+
# Restore
79+
importlib.reload(remi)
80+
81+
82+
class TestVersionHardcodedFallback(unittest.TestCase):
83+
"""When both importlib.metadata and pkg_resources cannot find the package,
84+
__version__ falls back to the hardcoded _FALLBACK_VERSION string."""
85+
86+
def test_hardcoded_fallback_used(self):
87+
from importlib.metadata import PackageNotFoundError
88+
89+
# importlib.metadata is importable but raises PackageNotFoundError
90+
with patch("importlib.metadata.version", side_effect=PackageNotFoundError("remi")):
91+
importlib.reload(remi)
92+
93+
# __version__ must still be a non-empty string (the hardcoded fallback)
94+
self.assertIsInstance(remi.__version__, str)
95+
self.assertTrue(len(remi.__version__) > 0)
96+
self.assertEqual(remi.__version__, remi._FALLBACK_VERSION)
97+
# Restore
98+
importlib.reload(remi)
99+
100+
def test_fallback_version_matches_setup(self):
101+
"""_FALLBACK_VERSION in __init__.py must match the version in setup.py."""
102+
setup_py = os.path.join(os.path.dirname(__file__), '..', 'setup.py')
103+
setup_py = os.path.normpath(setup_py)
104+
with open(setup_py) as f:
105+
content = f.read()
106+
# Extract the version string from setup.py: 'version': '...'
107+
import re
108+
match = re.search(r"'version'\s*:\s*'([^']+)'", content)
109+
self.assertIsNotNone(match, "Could not find 'version' in setup.py")
110+
setup_version = match.group(1)
111+
self.assertEqual(
112+
remi._FALLBACK_VERSION,
113+
setup_version,
114+
"_FALLBACK_VERSION in __init__.py (%s) does not match setup.py (%s)"
115+
% (remi._FALLBACK_VERSION, setup_version),
116+
)
117+
118+
119+
if __name__ == "__main__":
120+
unittest.main()

0 commit comments

Comments
 (0)