Refactor and add tests
This commit is contained in:
		
							parent
							
								
									e6c1981103
								
							
						
					
					
						commit
						50645e10a3
					
				
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -0,0 +1 @@ | ||||
| __pycache__/ | ||||
| @ -45,6 +45,10 @@ We plan to preserve existing Python flags in shebangs, but the work is not yet f | ||||
| The PEPs don't (yet) define a way to specify test dependencies and test runners. | ||||
| That means you still need to handle test dependencies and `%check` on your own. | ||||
| 
 | ||||
| Extras are currently ignored. | ||||
| 
 | ||||
| Some valid Python version specifiers are not supported. | ||||
| 
 | ||||
| 
 | ||||
| [PEP 517]: https://www.python.org/dev/peps/pep-0517/ | ||||
| [PEP 518]: https://www.python.org/dev/peps/pep-0518/ | ||||
|  | ||||
| @ -23,6 +23,6 @@ echo 'python3dist(packaging)' | ||||
| echo 'python3dist(pip) >= 19' | ||||
| echo 'python3dist(pytoml)' | ||||
| if [ -f %{__python3} ]; then | ||||
|   %{__python3} -I %{_rpmconfigdir}/redhat/pyproject_buildrequires.py | ||||
|   %{__python3} -I %{_rpmconfigdir}/redhat/pyproject_buildrequires.py %{?*} | ||||
| fi | ||||
| } | ||||
|  | ||||
| @ -12,6 +12,9 @@ Source1:        pyproject_buildrequires.py | ||||
| Source8:        README.md | ||||
| Source9:        LICENSE | ||||
| 
 | ||||
| Source10:       test_pyproject_buildrequires.py | ||||
| Source11:       testcases.yaml | ||||
| 
 | ||||
| URL:            https://src.fedoraproject.org/rpms/pyproject-rpm-macros | ||||
| 
 | ||||
| BuildArch:      noarch | ||||
| @ -23,6 +26,14 @@ BuildArch:      noarch | ||||
| Requires: python3-pip >= 19 | ||||
| Requires: python3-devel | ||||
| 
 | ||||
| # Test dependencies | ||||
| BuildRequires: python3dist(pytest) | ||||
| BuildRequires: python3dist(pyyaml) | ||||
| BuildRequires: python3dist(packaging) | ||||
| BuildRequires: python3dist(pytoml) | ||||
| BuildRequires: python3dist(pip) | ||||
| 
 | ||||
| 
 | ||||
| %description | ||||
| This is a provisional implementation of pyproject RPM macros for Fedora 30+. | ||||
| These macros are useful for packaging Python projects that use the PEP 517 | ||||
| @ -45,6 +56,10 @@ mkdir -p %{buildroot}%{_rpmconfigdir}/redhat | ||||
| install -m 644 macros.pyproject %{buildroot}%{_rpmmacrodir}/ | ||||
| install -m 644 pyproject_buildrequires.py %{buildroot}%{_rpmconfigdir}/redhat/ | ||||
| 
 | ||||
| %check | ||||
| %{__python3} -m pytest -vv | ||||
| 
 | ||||
| 
 | ||||
| %files | ||||
| %{_rpmmacrodir}/macros.pyproject | ||||
| %{_rpmconfigdir}/redhat/pyproject_buildrequires.py | ||||
|  | ||||
| @ -1,84 +1,216 @@ | ||||
| import sys | ||||
| import importlib | ||||
| import argparse | ||||
| import functools | ||||
| import traceback | ||||
| import contextlib | ||||
| from io import StringIO | ||||
| import subprocess | ||||
| import pathlib | ||||
| import re | ||||
| 
 | ||||
| print_err = functools.partial(print, file=sys.stderr) | ||||
| 
 | ||||
| # Some valid Python version specifiers are not supported. | ||||
| # Whitelist characters we can handle. | ||||
| VERSION_RE = re.compile('[a-zA-Z0-9.-]+') | ||||
| 
 | ||||
| class EndPass(Exception): | ||||
|     """End current pass of generating requirements""" | ||||
| 
 | ||||
| try: | ||||
|     import pytoml | ||||
|     from packaging.requirements import Requirement, InvalidRequirement | ||||
|     from packaging.version import Version | ||||
|     from packaging.utils import canonicalize_name, canonicalize_version | ||||
| except ImportError: | ||||
|     import pip | ||||
| except ImportError as e: | ||||
|     print_err('Import error:', e) | ||||
|     # already echoed by the %pyproject_buildrequires macro | ||||
|     sys.exit(0) | ||||
| 
 | ||||
| 
 | ||||
| @contextlib.contextmanager | ||||
| def hook_call(): | ||||
|     captured_out = StringIO() | ||||
|     with contextlib.redirect_stdout(captured_out): | ||||
|         yield | ||||
|     for line in captured_out.getvalue().splitlines(): | ||||
|         print_err('HOOK STDOUT:', line) | ||||
| 
 | ||||
| 
 | ||||
| class Requirements: | ||||
|     """Requirement printer""" | ||||
|     def __init__(self, freeze_output): | ||||
|         self.installed_packages = {} | ||||
|         for line in freeze_output.splitlines(): | ||||
|             line = line.strip() | ||||
|             if line.startswith('#'): | ||||
|                 continue | ||||
|             name, version = line.split('==') | ||||
|             self.installed_packages[name.strip()] = Version(version) | ||||
| 
 | ||||
|         self.missing_requirements = False | ||||
| 
 | ||||
|     def add(self, requirement_str, *, source=None): | ||||
|         """Output a Python-style requirement string as RPM dep""" | ||||
|         print_err(f'Handling {requirement_str} from {source}') | ||||
| 
 | ||||
|         try: | ||||
|     f = open("pyproject.toml") as f | ||||
|             requirement = Requirement(requirement_str) | ||||
|         except InvalidRequirement as e: | ||||
|             print_err( | ||||
|                 f'"WARNING: Skipping invalid requirement: {requirement_str}\n' | ||||
|                 + f'    {e}', | ||||
|             ) | ||||
|             return | ||||
| 
 | ||||
|         name = canonicalize_name(requirement.name) | ||||
|         if requirement.marker is not None and not requirement.marker.evaluate(): | ||||
|             print_err(f'Ignoring alien requirement:', requirement_str) | ||||
|             return | ||||
| 
 | ||||
|         installed = self.installed_packages.get(requirement.name) | ||||
|         if installed and installed in requirement.specifier: | ||||
|             print_err(f'Requirement satisfied: {requirement_str}') | ||||
|             print_err(f'   (installed: {requirement.name} {installed})') | ||||
|         else: | ||||
|             self.missing_requirements = True | ||||
| 
 | ||||
|         together = [] | ||||
|         for specifier in sorted( | ||||
|             requirement.specifier, | ||||
|             key=lambda s: (s.operator, s.version), | ||||
|         ): | ||||
|             version = canonicalize_version(specifier.version) | ||||
|             print_err(version) | ||||
|             if not VERSION_RE.fullmatch(str(specifier.version)): | ||||
|                 raise ValueError( | ||||
|                     f'Unknown character in version: {specifier.version}. ' | ||||
|                     + '(This is probably a bug in pyproject-rpm-macros.)', | ||||
|                 ) | ||||
|             if specifier.operator == "!=": | ||||
|                 lower = python3dist(name, '<', version) | ||||
|                 higher = python3dist(name, '>', f'{version}.0') | ||||
|                 together.append( | ||||
|                     f"({lower} or {higher})" | ||||
|                 ) | ||||
|             else: | ||||
|                 together.append(python3dist(name, specifier.operator, version)) | ||||
|         if len(together) == 0: | ||||
|             print(python3dist(name)) | ||||
|         elif len(together) == 1: | ||||
|             print(together[0]) | ||||
|         else: | ||||
|             print(f"({' and '.join(together)})") | ||||
| 
 | ||||
|     def check(self, *, source=None): | ||||
|         """End current pass if any unsatisfied dependencies were output""" | ||||
|         if self.missing_requirements: | ||||
|             print_err(f'Exiting dependency generation pass: {source}') | ||||
|             raise EndPass(source) | ||||
| 
 | ||||
|     def extend(self, requirement_strs, *, source=None): | ||||
|         """add() several requirements""" | ||||
|         for req_str in requirement_strs: | ||||
|             self.add(req_str, source=source) | ||||
| 
 | ||||
| def get_backend(requirements): | ||||
|     try: | ||||
|         f = open('pyproject.toml') | ||||
|     except FileNotFoundError: | ||||
|         pyproject_data = {} | ||||
|     else: | ||||
|         with f: | ||||
|             pyproject_data = pytoml.load(f) | ||||
| 
 | ||||
|     try: | ||||
|         backend_name = pyproject_data["build-system"]["build-backend"] | ||||
|     except KeyError: | ||||
|         try: | ||||
|             import setuptools.build_meta | ||||
|         except ImportError: | ||||
|             print("python3dist(setuptools) >= 40.8") | ||||
|             print("python3dist(wheel)") | ||||
|             sys.exit(0) | ||||
| 
 | ||||
|         backend = setuptools.build_meta | ||||
|     else: | ||||
|         try: | ||||
|             backend = importlib.import_module(backend_name) | ||||
|         except ImportError: | ||||
|             backend = None | ||||
| 
 | ||||
| 
 | ||||
| requirements = set() | ||||
| rpm_requirements = set() | ||||
| 
 | ||||
| 
 | ||||
| def add_requirement(requirement): | ||||
|     try: | ||||
|         requirements.add(Requirement(requirement)) | ||||
|     except InvalidRequirement as e: | ||||
|         print( | ||||
|             f"WARNING: Skipping invalid requirement: {requirement}\n         {e}", | ||||
|             file=sys.stderr, | ||||
|     buildsystem_data = pyproject_data.get("build-system", {}) | ||||
|     requirements.extend( | ||||
|         buildsystem_data.get("requires", ()), | ||||
|         source='build-system.requires', | ||||
|     ) | ||||
| 
 | ||||
|     backend_name = buildsystem_data.get('build-backend') | ||||
|     if not backend_name: | ||||
|         requirements.add("setuptools >= 40.8", source='default build backend') | ||||
|         requirements.add("wheel", source='default build backend') | ||||
| 
 | ||||
| if "requires" in pyproject_data.get("build-system", {}): | ||||
|     for requirement in pyproject_data["build-system"]["requires"]: | ||||
|         add_requirement(requirement) | ||||
|         backend_name = 'setuptools.build_meta' | ||||
| 
 | ||||
|     requirements.check(source='build backend') | ||||
| 
 | ||||
|     backend_path = buildsystem_data.get('backend-path') | ||||
|     if backend_path: | ||||
|         sys.path.insert(0, backend_path) | ||||
| 
 | ||||
|     return importlib.import_module(backend_name) | ||||
| 
 | ||||
| 
 | ||||
| def generate_build_requirements(backend, requirements): | ||||
|     get_requires = getattr(backend, "get_requires_for_build_wheel", None) | ||||
|     if get_requires: | ||||
|     for requirement in get_requires(): | ||||
|         add_requirement(requirement) | ||||
|         with hook_call(): | ||||
|             new_reqs = get_requires() | ||||
|         requirements.extend(new_reqs, source='get_requires_for_build_wheel') | ||||
| 
 | ||||
| for requirement in requirements: | ||||
|     name = canonicalize_name(requirement.name) | ||||
|     if requirement.marker is not None and not requirement.marker.evaluate(): | ||||
|         continue | ||||
|     together = [] | ||||
|     for specifier in requirement.specifier: | ||||
|         version = canonicalize_version(specifier.version) | ||||
|         if specifier.operator == "!=": | ||||
|             together.append( | ||||
|                 f"(python3dist({name}) < {version} or python3dist({name}) >= {version}.0)" | ||||
|             ) | ||||
| 
 | ||||
| def python3dist(name, op=None, version=None): | ||||
|     if op is None: | ||||
|         if version is not None: | ||||
|             raise AssertionError('op and version go together') | ||||
|         return f'python3dist({name})' | ||||
|     else: | ||||
|             together.append(f"python3dist({name}) {specifier.operator} {version}") | ||||
|     if len(together) == 0: | ||||
|         rpm_requirements.add(f"python3dist({name})") | ||||
|     if len(together) == 1: | ||||
|         rpm_requirements.add(together[0]) | ||||
|     elif len(together) > 1: | ||||
|         rpm_requirements.add(f"({' and '.join(together)})") | ||||
|         return f'python3dist({name}) {op} {version}' | ||||
| 
 | ||||
| 
 | ||||
| print(*sorted(rpm_requirements), sep="\n") | ||||
| def generate_requires(freeze_output): | ||||
|     requirements = Requirements(freeze_output) | ||||
| 
 | ||||
|     try: | ||||
|         backend = get_backend(requirements) | ||||
|         generate_build_requirements(backend, requirements) | ||||
|     except EndPass: | ||||
|         return 0 | ||||
| 
 | ||||
| 
 | ||||
| def main(argv): | ||||
|     parser = argparse.ArgumentParser( | ||||
|         description='Generate BuildRequires for a Python project.' | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         '--runtime', action='store_true', | ||||
|         help='Generate run-time requirements (not implemented)', | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         '--toxenv', metavar='TOXENVS', | ||||
|         help='generate test tequirements from tox environment ' | ||||
|             + '(not implemented; implies --runtime)', | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         '--pyproject-file', default='pyproject.toml', | ||||
|         help='override project file (default: pyproject.toml)', | ||||
|     ) | ||||
| 
 | ||||
|     args = parser.parse_args(argv) | ||||
|     if args.toxenv: | ||||
|         args.runtime = True | ||||
|     if args.runtime: | ||||
|         print_err('--runtime is not implemented') | ||||
|         exit(1) | ||||
| 
 | ||||
|     freeze_output = subprocess.run( | ||||
|         ['pip', 'freeze', '--all'], | ||||
|         stdout=subprocess.PIPE, | ||||
|         check=True, | ||||
|     ).stdout | ||||
| 
 | ||||
|     try: | ||||
|         generate_requires(freeze_output) | ||||
|     except Exception as e: | ||||
|         # Log the traceback explicitly (it's useful debug info) | ||||
|         traceback.print_exc() | ||||
|         exit(1) | ||||
| 
 | ||||
| 
 | ||||
| if __name__ == '__main__': | ||||
|     main(sys.argv[1:]) | ||||
|  | ||||
							
								
								
									
										40
									
								
								test_pyproject_buildrequires.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								test_pyproject_buildrequires.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,40 @@ | ||||
| from pathlib import Path | ||||
| import io | ||||
| 
 | ||||
| import pytest | ||||
| import yaml | ||||
| 
 | ||||
| from pyproject_buildrequires import generate_requires | ||||
| 
 | ||||
| testcases = {} | ||||
| with Path(__file__).parent.joinpath('testcases.yaml').open() as f: | ||||
|     testcases = yaml.safe_load(f) | ||||
| 
 | ||||
| 
 | ||||
| @pytest.mark.parametrize('case_name', testcases) | ||||
| def test_data(case_name, capsys, tmp_path, monkeypatch): | ||||
|     case = testcases[case_name] | ||||
| 
 | ||||
|     cwd = tmp_path.joinpath('cwd') | ||||
|     cwd.mkdir() | ||||
|     monkeypatch.chdir(cwd) | ||||
| 
 | ||||
|     if 'pyproject.toml' in case: | ||||
|         cwd.joinpath('pyproject.toml').write_text(case['pyproject.toml']) | ||||
| 
 | ||||
|     if 'setup.py' in case: | ||||
|         cwd.joinpath('setup.py').write_text(case['setup.py']) | ||||
| 
 | ||||
|     try: | ||||
|         generate_requires( | ||||
|             case['freeze_output'], | ||||
|         ) | ||||
|     except SystemExit as e: | ||||
|         assert e.code == case['result'] | ||||
|     except Exception as e: | ||||
|         assert type(e).__name__ == case['except'] | ||||
|     else: | ||||
|         assert 0 == case['result'] | ||||
| 
 | ||||
|         captured = capsys.readouterr() | ||||
|         assert captured.out == case['expected'] | ||||
							
								
								
									
										119
									
								
								testcases.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								testcases.yaml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,119 @@ | ||||
| No pyproject.toml, nothing installed: | ||||
|   freeze_output: | | ||||
|     # empty | ||||
|   expected: | | ||||
|     python3dist(setuptools) >= 40.8 | ||||
|     python3dist(wheel) | ||||
|   result: 0 | ||||
| 
 | ||||
| Nothing installed yet: | ||||
|   freeze_output: | | ||||
|     # empty | ||||
|   pyproject.toml: | | ||||
|     # empty | ||||
|   expected: | | ||||
|     python3dist(setuptools) >= 40.8 | ||||
|     python3dist(wheel) | ||||
|   result: 0 | ||||
| 
 | ||||
| Insufficient version of setuptools: | ||||
|   freeze_output: | | ||||
|     setuptools==5 | ||||
|     wheel==1 | ||||
|   pyproject.toml: | | ||||
|     # empty | ||||
|   expected: | | ||||
|     python3dist(setuptools) >= 40.8 | ||||
|     python3dist(wheel) | ||||
|   result: 0 | ||||
| 
 | ||||
| Empty pyproject.toml, empty setup.py: | ||||
|   freeze_output: | | ||||
|     setuptools==50 | ||||
|     wheel==1 | ||||
|   setup.py: | | ||||
|   expected: | | ||||
|     python3dist(setuptools) >= 40.8 | ||||
|     python3dist(wheel) | ||||
|     python3dist(wheel) | ||||
|   result: 0 | ||||
| 
 | ||||
| Default build system, empty setup.py: | ||||
|   freeze_output: | | ||||
|     setuptools==50 | ||||
|     wheel==1 | ||||
|   pyproject.toml: | | ||||
|     # empty | ||||
|   setup.py: | | ||||
|   expected: | | ||||
|     python3dist(setuptools) >= 40.8 | ||||
|     python3dist(wheel) | ||||
|     python3dist(wheel) | ||||
|   result: 0 | ||||
| 
 | ||||
| Erroring setup.py: | ||||
|   freeze_output: | | ||||
|     setuptools==50 | ||||
|     wheel==1 | ||||
|   setup.py: | | ||||
|     exit(77) | ||||
|   result: 77 | ||||
| 
 | ||||
| Bad character in version: | ||||
|   freeze_output: | | ||||
|   pyproject.toml: | | ||||
|     [build-system] | ||||
|     requires = ["pkg == 0.$.^.*"] | ||||
|   except: ValueError | ||||
| 
 | ||||
| Build system dependencies in pyproject.toml: | ||||
|   freeze_output: | | ||||
|     setuptools==50 | ||||
|     wheel==1 | ||||
|   pyproject.toml: | | ||||
|     [build-system] | ||||
|     requires = [ | ||||
|         "foo", | ||||
|         "ne!=1", | ||||
|         "ge>=1.2", | ||||
|         "le <= 1.2.3", | ||||
|         "lt < 1.2.3.4      ", | ||||
|         "    gt > 1.2.3.4.5", | ||||
|         "combo >2, <5, != 3.0.0", | ||||
|         "invalid!!ignored", | ||||
|         "py2 ; python_version < '2.7'", | ||||
|         "py3 ; python_version > '3.0'", | ||||
|         "pkg [extra-currently-ignored]", | ||||
|     ] | ||||
|   expected: | | ||||
|     python3dist(foo) | ||||
|     (python3dist(ne) < 1 or python3dist(ne) > 1.0) | ||||
|     python3dist(ge) >= 1.2 | ||||
|     python3dist(le) <= 1.2.3 | ||||
|     python3dist(lt) < 1.2.3.4 | ||||
|     python3dist(gt) > 1.2.3.4.5 | ||||
|     ((python3dist(combo) < 3 or python3dist(combo) > 3.0) and python3dist(combo) < 5 and python3dist(combo) > 2) | ||||
|     python3dist(py3) | ||||
|     python3dist(pkg) | ||||
|     python3dist(setuptools) >= 40.8 | ||||
|     python3dist(wheel) | ||||
|   result: 0 | ||||
| 
 | ||||
| Default build system, dependencies in setup.py: | ||||
|   freeze_output: | | ||||
|     setuptools==50 | ||||
|     wheel==1 | ||||
|   setup.py: | | ||||
|     from setuptools import setup | ||||
|     setup( | ||||
|         name='test', | ||||
|         version='0.1', | ||||
|         setup_requires=['foo', 'bar!=2'], | ||||
|     ) | ||||
|   expected: | | ||||
|     python3dist(setuptools) >= 40.8 | ||||
|     python3dist(wheel) | ||||
|     python3dist(wheel) | ||||
|     python3dist(foo) | ||||
|     (python3dist(bar) < 2 or python3dist(bar) > 2.0) | ||||
|   result: 0 | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user