diff --git a/remi/__init__.py b/remi/__init__.py index 77b27cf8..76563717 100644 --- a/remi/__init__.py +++ b/remi/__init__.py @@ -32,32 +32,26 @@ from .server import App, Server, start -# importlib.metadata is available in Python 3.8+ -useForVersionCheck = None -try: - import importlib.metadata - useForVersionCheck = "importlib.metadata" -except ImportError: - try: - import pkg_resources - useForVersionCheck = "pkg_resources" - except ImportError: - pass +# Hardcoded fallback version — used when package metadata is unavailable +# (e.g. PyInstaller frozen builds, editable installs without metadata). +# Keep in sync with setup.py. +_FALLBACK_VERSION = "2026.03.24" +__version__ = _FALLBACK_VERSION -if useForVersionCheck == "importlib.metadata": +# importlib.metadata is available in Python 3.8+; pkg_resources for older. +try: from importlib.metadata import version, PackageNotFoundError try: __version__ = version(__name__) except PackageNotFoundError: - # package is not installed - pass -elif useForVersionCheck == "pkg_resources": - from pkg_resources import get_distribution, DistributionNotFound + pass # metadata absent (frozen build etc.) — fallback stays +except ImportError: try: - __version__ = get_distribution(__name__).version - except DistributionNotFound: - # package is not installed - pass -else: - # neither importlib.metadata nor pkg_resources is available - print("WARNING: cannot check remi version, please install importlib-metadata (python >= 3.8) or the pkg_resources module by installing setuptools") \ No newline at end of file + from pkg_resources import get_distribution, DistributionNotFound + try: + __version__ = get_distribution(__name__).version + except DistributionNotFound: + pass # package not installed — fallback stays + except ImportError: + print("WARNING: cannot check remi version, please install " + "importlib-metadata (Python >= 3.8) or setuptools") \ No newline at end of file diff --git a/remi/server.py b/remi/server.py index 983708ce..0990d5e6 100644 --- a/remi/server.py +++ b/remi/server.py @@ -47,9 +47,47 @@ from urllib.parse import unquote_to_bytes from urllib.parse import urlparse from urllib.parse import parse_qs -import cgi +import io import weakref +# cgi.FieldStorage was removed in Python 3.13 (deprecated since 3.11). +# Provide a minimal replacement using email.parser when cgi is absent. +try: + from cgi import FieldStorage as _FieldStorage + _HAS_CGI = True +except ImportError: + import email.parser as _email_parser + _HAS_CGI = False + + class _FieldItem: + """Minimal stand-in for a cgi.FieldStorage field (file upload part).""" + def __init__(self, filename, data): + self.filename = filename + self.file = io.BytesIO(data if data is not None else b'') + + class _FieldStorage: + """Minimal stand-in for cgi.FieldStorage, used only in do_POST (file uploads).""" + def __init__(self, fp, headers, environ): + self._fields = {} + content_length = int(headers.get('Content-Length', 0)) + body = fp.read(content_length) + content_type = environ.get('CONTENT_TYPE', headers.get('Content-Type', '')) + raw = ('Content-Type: %s\r\n\r\n' % content_type).encode('utf-8') + body + msg = _email_parser.BytesParser().parsebytes(raw) + if msg.is_multipart(): + for part in msg.get_payload(): + name = part.get_param('name', header='content-disposition') + filename = part.get_param('filename', header='content-disposition') + if name: + data = part.get_payload(decode=True) + self._fields[name] = _FieldItem(filename, data) + + def keys(self): + return self._fields.keys() + + def __getitem__(self, key): + return self._fields[key] + import zlib import select @@ -574,10 +612,10 @@ def do_POST(self): filename = self.headers['filename'] listener_widget = runtimeInstances[self.headers['listener']] listener_function = self.headers['listener_function'] - form = cgi.FieldStorage(fp=self.rfile, - headers=self.headers, - environ={'REQUEST_METHOD': 'POST', - 'CONTENT_TYPE': self.headers['Content-Type']}) + form = _FieldStorage(fp=self.rfile, + headers=self.headers, + environ={'REQUEST_METHOD': 'POST', + 'CONTENT_TYPE': self.headers['Content-Type']}) # Echo back information about what was posted in the form for field in form.keys(): field_item = form[field] @@ -762,8 +800,8 @@ def onload(self, emitter): def onerror(self, message, source, lineno, colno, error): """ WebPage Event that occurs on webpage errors """ - self._log.debug("""App.onerror event occurred in webpage: - \nMESSAGE:%s\nSOURCE:%s\nLINENO:%s\nCOLNO:%s\ERROR:%s\n"""%(message, source, lineno, colno, error)) + self._log.debug("""App.onerror event occurred in webpage: + \nMESSAGE:%s\nSOURCE:%s\nLINENO:%s\nCOLNO:%s\\ERROR:%s\n"""%(message, source, lineno, colno, error)) def ononline(self, emitter): """ WebPage Event that occurs on webpage goes online after a disconnection diff --git a/setup.py b/setup.py index 3591aaef..263e7aee 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ 'packages':setuptools.find_packages(), 'include_package_data':True, 'setup_requires':['setuptools_scm'], - 'version': '2026.02.04', + 'version': '2026.03.24', } try: setup(**params) diff --git a/test/test_version.py b/test/test_version.py new file mode 100644 index 00000000..b91cf5b2 --- /dev/null +++ b/test/test_version.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python +""" +Tests for Python 3.13 / PyInstaller compatibility: + - __version__ is always defined (even when package metadata is absent) + - importlib.metadata path is used on Python 3.8+ + - pkg_resources fallback is used when importlib.metadata is unavailable + - Hardcoded fallback is used when neither can find the package + - server.py contains no invalid escape sequences (SyntaxWarning-free) +""" + +import importlib +import py_compile +import os +import sys +import unittest +import warnings +from unittest.mock import patch, MagicMock + +import remi + +# Absolute path to server.py so tests run from any working directory +_SERVER_PY = os.path.join(os.path.dirname(__file__), '..', 'remi', 'server.py') +_SERVER_PY = os.path.normpath(_SERVER_PY) + + +class TestNoSyntaxWarnings(unittest.TestCase): + """server.py must compile without any SyntaxWarning (e.g. invalid escape sequences).""" + + def test_server_py_no_syntax_warnings(self): + """Compiling server.py raises no SyntaxWarning on Python 3.12+.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", SyntaxWarning) + try: + py_compile.compile(_SERVER_PY, doraise=True) + except py_compile.PyCompileError as exc: + self.fail("server.py has a SyntaxWarning/SyntaxError: %s" % exc) + + +class TestVersionAlwaysDefined(unittest.TestCase): + """remi.__version__ must always be a non-empty string.""" + + def test_version_is_string(self): + self.assertIsInstance(remi.__version__, str) + + def test_version_non_empty(self): + self.assertTrue(len(remi.__version__) > 0) + + +class TestVersionImportlibMetadataPath(unittest.TestCase): + """When importlib.metadata.version() succeeds, __version__ is taken from it.""" + + def test_importlib_metadata_used(self): + fake_version = "9999.01.01" + with patch("importlib.metadata.version", return_value=fake_version): + importlib.reload(remi) + self.assertEqual(remi.__version__, fake_version) + # Restore + importlib.reload(remi) + + +class TestVersionPkgResourcesFallback(unittest.TestCase): + """When importlib.metadata raises ImportError, pkg_resources provides the version.""" + + def test_pkg_resources_fallback(self): + fake_version = "8888.06.15" + + mock_dist = MagicMock() + mock_dist.version = fake_version + mock_pkg = MagicMock() + mock_pkg.get_distribution.return_value = mock_dist + mock_pkg.DistributionNotFound = Exception + + # Make importlib.metadata unavailable, supply a mock pkg_resources + with patch.dict(sys.modules, {"importlib.metadata": None, "pkg_resources": mock_pkg}): + importlib.reload(remi) + + self.assertEqual(remi.__version__, fake_version) + # Restore + importlib.reload(remi) + + +class TestVersionHardcodedFallback(unittest.TestCase): + """When both importlib.metadata and pkg_resources cannot find the package, + __version__ falls back to the hardcoded _FALLBACK_VERSION string.""" + + def test_hardcoded_fallback_used(self): + from importlib.metadata import PackageNotFoundError + + # importlib.metadata is importable but raises PackageNotFoundError + with patch("importlib.metadata.version", side_effect=PackageNotFoundError("remi")): + importlib.reload(remi) + + # __version__ must still be a non-empty string (the hardcoded fallback) + self.assertIsInstance(remi.__version__, str) + self.assertTrue(len(remi.__version__) > 0) + self.assertEqual(remi.__version__, remi._FALLBACK_VERSION) + # Restore + importlib.reload(remi) + + def test_fallback_version_matches_setup(self): + """_FALLBACK_VERSION in __init__.py must match the version in setup.py.""" + setup_py = os.path.join(os.path.dirname(__file__), '..', 'setup.py') + setup_py = os.path.normpath(setup_py) + with open(setup_py) as f: + content = f.read() + # Extract the version string from setup.py: 'version': '...' + import re + match = re.search(r"'version'\s*:\s*'([^']+)'", content) + self.assertIsNotNone(match, "Could not find 'version' in setup.py") + setup_version = match.group(1) + self.assertEqual( + remi._FALLBACK_VERSION, + setup_version, + "_FALLBACK_VERSION in __init__.py (%s) does not match setup.py (%s)" + % (remi._FALLBACK_VERSION, setup_version), + ) + + +if __name__ == "__main__": + unittest.main()