diff --git a/README.md b/README.md index 63fbc77..3058f09 100644 --- a/README.md +++ b/README.md @@ -74,16 +74,16 @@ The popular buildsystems (setuptools, flit, poetry) do support it. This behavior can be disabled (e.g. when the project's build system does not support it) -using the `-R` flag: +using the `-R`/`--no-runtime` flag: %generate_buildrequires - %pyproject_buildrequires -R + %pyproject_buildrequires --no-runtime Alternatively, if the project specifies its dependencies in the pyproject.toml `[project]` table (as defined in [PEP 621]), the runtime dependencies can be obtained by reading that metadata. -This can be enabled by using the `-p` flag. +This can be enabled by using the `-p`/`--pyproject-dependencies` flag. This flag supports reading both the runtime dependencies, and the selected extras (see the `-x` flag described below). @@ -94,7 +94,7 @@ custom `[tool.poetry]` table which is not supported by the `%pyproject_buildrequ For projects that specify test requirements using an [`extra` provide](https://packaging.python.org/specifications/core-metadata/#provides-extra-multiple-use), -these can be added using the `-x` flag. +these can be added using the `-x`/`--extras` flag. Multiple extras can be supplied by repeating the flag or as a comma separated list. For example, if upstream suggests installing test dependencies with `pip install mypackage[testing]`, the test deps would be generated by: @@ -107,7 +107,7 @@ If a requested extra is not found after PEP 685 validation, the build fails with on Fedora >=45 and RHEL >=11. It will emit a warning instead on older releases. For projects that specify test requirements using [PEP 735] dependency groups, -these can be added using the `-g` flag. +these can be added using the `-g`/`--dependency-groups` flag. Multiple groups can be supplied by repeating the flag or as a comma separated list. For example, if upstream uses a dependency group called `tests`, the test deps would be generated by: @@ -115,8 +115,8 @@ For example, if upstream uses a dependency group called `tests`, the test deps w %pyproject_buildrequires -g tests For projects that specify test requirements in their [tox] configuration, -these can be added using the `-t` flag (default tox environment) -or the `-e` flag followed by the tox environment. +these can be added using the `-t`/`--tox` flag (default tox environment) +or the `-e`/`--toxenv` flag followed by the tox environment. The default tox environment (such as `py37` assuming the Fedora's Python version is 3.7) is available in the `%{toxenv}` macro. For example, if upstream suggests running the tests on Python 3.7 with `tox -e py37`, @@ -146,11 +146,11 @@ such plugins will be BuildRequired as well. Not all plugins are guaranteed to play well with [tox-current-env], in worst case, patch/sed the requirement out from the tox configuration. -Note that neither `-x` or `-t` can be used with `-R` or `-N`, +Note that neither `-x`/`--extras` or `-t`/`--tox` can be used with `-R`/`--no-runtime` or `-N`/`--no-use-build-system`, because runtime dependencies are always required for testing. You can only use those options if the build backend supports the [prepare-metadata-for-build-wheel hook], or together with `-p`. -However, using `-g` with `-R` or `-N` is supported because dependency groups don't need to be used for testing +However, using `-g`/`--dependency-groups` with `-R`/`--no-runtime` or `-N`/`--no-use-build-system` is supported because dependency groups don't need to be used for testing and can be obtained by reading `pyproject.toml` only. [tox]: https://tox.readthedocs.io/ @@ -163,24 +163,24 @@ Dependencies will be loaded from them: %pyproject_buildrequires requirements/tests.in requirements/docs.in requirements/dev.in -For packages not using build system you can use `-N` to entirely skip automatical +For packages not using build system you can use `-N`/`--no-use-build-system` to entirely skip automatical generation of requirements and install requirements only from manually specified files. -`-N` option implies `-R` and cannot be used in combination with other options mentioned above -(`-e`, `-t`, `-x`, `-p`). +`-N`/`--no-use-build-system` option implies `-R`/`--no-runtime` and cannot be used in combination with other options mentioned above +(`-e`/`--toxenv`, `-t`/`--tox`, `-x`/`--extras`, `-p`/`--pyproject-dependencies`). -The `%pyproject_buildrequires` macro also accepts the `-r` flag for backward compatibility; +The `%pyproject_buildrequires` macro also accepts the `-r`/`--runtime` flag for backward compatibility; it means "include runtime dependencies" which has been the default since version 0-53. Building wheels from custom directories --------------------------------------- -The `%pyproject_buildrequires` and `%pyproject_wheel` macros accept a `-d` flag +The `%pyproject_buildrequires` and `%pyproject_wheel` macros accept a `-d`/`--directory` flag to specify a working directory. For example: - %pyproject_wheel -d bindings/python + %pyproject_wheel --directory bindings/python Is roughly equivalent to: @@ -208,17 +208,18 @@ with the `:br_only` suffix to mitigate this problem. Passing config settings to build backends ----------------------------------------- -The `%pyproject_buildrequires` and `%pyproject_wheel` macros accept a `-C` flag +The `%pyproject_buildrequires` and `%pyproject_wheel` macros accept a `-C`/`--config-settings` flag to pass [configuration settings][config_settings] to the build backend. -Options take the form of `-C KEY`, `-C KEY=VALUE`, or `-C--option-with-dashes`. -Pass `-C` multiple times to specify multiple options. +Options take the form of `-C KEY`, `-C KEY=VALUE`, `-C--option-with-dashes`, +or `--config-settings KEY=VALUE`. +Pass `-C`/`--config-settings` multiple times to specify multiple options. This option is equivalent to pip's `--config-settings` flag. These are passed on to PEP 517 hooks' `config_settings` argument as a Python dictionary. The `%pyproject_buildrequires` macro passes these options to the `get_requires_for_build_wheel` and `prepare_metadata_for_build_wheel` hooks. -Passing `-C` to `%pyproject_buildrequires` is incompatible with `-N` which does +Passing `-C`/`--config-settings` to `%pyproject_buildrequires` is incompatible with `-N`/`--no-use-build-system` which does not call these hooks at all. The `%pyproject_wheel` macro passes these options to the `build_wheel` hook. @@ -231,7 +232,7 @@ and other projects may only accept config settings for one of the two steps. Note that the current implementation of the macros uses `pip` to build wheels. On some systems (notably on RHEL 9 with Python 3.9), `pip` is too old to understand `--config-settings`. -Using the `-C` option for `%pyproject_wheel` (or `%pyproject_buildrequires -w`) +Using the `-C`/`--config-settings` option for `%pyproject_wheel` (or `%pyproject_buildrequires -w`/`--wheel`) is not supported there and will result to an error like: Usage: @@ -323,12 +324,12 @@ The macro: - Runs `tox` with `-q` (quiet), `--recreate`, `--current-env` (from [tox-current-env]) and `--assert-config` (from [tox-current-env]) flags - Implicitly uses the tox environment name stored in `%{toxenv}` - as overridden by `%pyproject_buildrequires -e` -By using the `-e` flag, you can use a different tox environment(s): +By using the `-e`/`--toxenv` flag, you can use a different tox environment(s): %check %tox %if %{with integration_test} - %tox -e %{default_toxenv}-integration + %tox --toxenv %{default_toxenv}-integration %endif If you wish to provide custom `tox` flags or arguments, add them after `--`: @@ -388,7 +389,7 @@ However, in Fedora packages, always list executables explicitly to avoid uninten %doc README.rst %{_bindir}/downloader -If the package has no Python modules in it, you can explicitly use `-M` to denote that. +If the package has no Python modules in it, you can explicitly use `-M`/`--allow-no-modules` to denote that. %install %pyproject_install @@ -399,8 +400,8 @@ Otherwise, at least one module-glob argument is required. `%pyproject_save_files` can automatically mark license files with `%license` macro and language (`*.mo`) files with `%lang` macro and appropriate language code. Only license files declared via [PEP 639] `License-File` field are detected. -It is possible to use the `-l` flag to declare that a missing license should -terminate the build or `-L` (the default) to explicitly disable this check. +It is possible to use the `-l`/`--assert-license` flag to declare that a missing license should +terminate the build or `-L`/`--no-assert-license` (the default) to explicitly disable this check. Packagers are encouraged to use the `-l` flag when the `%license` file is not manually listed in `%files` to avoid accidentally losing the file in a future version. When the `%license` file is manually listed in `%files`, @@ -436,7 +437,7 @@ Use the macro in `%check`: %check %pyproject_check_import -By using the `-e` flag, you can exclude module names matching the given glob(s) from the import check +By using the `-e`/`--exclude` flag, you can exclude module names matching the given glob(s) from the import check (put it in single quotes to prevent Shell from expanding it). The flag can be used repeatedly. For example, to exclude all submodules ending with `config` and all submodules starting with `test`, you can use: @@ -446,7 +447,7 @@ For example, to exclude all submodules ending with `config` and all submodules s There must be at least one module left for the import check; if, as a result of greedy excluding, no modules are left to check, the check fails. -When the `-t` flag is used, only top-level modules are checked, +When the `-t`/`--top-level-only` flag is used, only top-level modules are checked, qualified module names with a dot (`.`) are excluded. If the modules detected by `%pyproject_save_files` are `requests`, `requests.models`, and `requests.packages`, this will only perform an import of `requests`: @@ -457,7 +458,7 @@ The reason should be documented in a comment. The `%pyproject_check_import` macro also accepts positional arguments with additional qualified module names to check, useful for example if some modules are installed manually. -Note that filtering by `-t`/`-e` also applies to the positional arguments. +Note that filtering by `-t`/`--top-level-only`/`-e`/`--exclude` also applies to the positional arguments. Another macro, `%_pyproject_check_import_allow_no_modules` allows to pass the import check, even if no Python modules are detected in the package. @@ -532,23 +533,23 @@ That way, RPM will automatically fill-in the `%prep`, `%generate_buildrequires`, To pass options to the individual macros, use `BuildOption` (see the [documentation of declarative buildsystems][declarative buildsystem]). # pass options for %%pyproject_save_files (mandatory when not overriding %%install) - BuildOption(install): -l _module +auto + BuildOption(install): --assert-license _module +auto # replace the default options for %%autosetup BuildOption(prep): -S git_am -C # pass options to %%pyproject_buildrequires - BuildOption(generate_buildrequires): docs-requirements.txt -t + BuildOption(generate_buildrequires): docs-requirements.txt --tox # pass options to %%pyproject_wheel BuildOption(build): -C--global-option=--no-cython-compile # pass options to %%pyproject_check_import - BuildOption(check): -e '*.test*' + BuildOption(check): --exclude '*.test*' Alternatively, you can supply your own sections to override the automatic ones: - BuildOption(generate_buildrequires): -w + BuildOption(generate_buildrequires): --wheel ... %build # do nothing, the wheel was built in %%generate_buildrequires @@ -568,6 +569,60 @@ Or prepend to the beginning of them: [declarative buildsystem]: https://rpm-software-management.github.io/rpm/manual/buildsystem.html +Long option support and options reference +----------------------------------------- + +All public macros accept both short and long options. +For example, `%pyproject_buildrequires -R` and `%pyproject_buildrequires --no-runtime` +are equivalent. + +Here is a complete reference of all options: + +### `%pyproject_buildrequires` + +| Short | Long | Description | +|---------------|------------------------------|----------------------------------------------------------| +| `-r` | `--runtime` | Generate run-time requirements (default) | +| `-R` | `--no-runtime` | Don't generate run-time requirements | +| `-x EXTRAS` | `--extras EXTRAS` | Comma-separated list of extras | +| `-t` | `--tox` | Generate test requirements from tox | +| `-e TOXENVS` | `--toxenv TOXENVS` | Specify tox environments | +| `-g GROUPS` | `--dependency-groups GROUPS` | Comma-separated dependency groups (PEP 735) | +| `-p` | `--pyproject-dependencies` | Read dependencies from pyproject.toml `[project]` table | +| `-N` | `--no-use-build-system` | Project does not use a build system | +| `-w` | `--wheel` | Build wheel to get runtime requirements (deprecated) | +| `-C SETTING` | `--config-settings SETTING` | Configuration setting for PEP 517 backend | +| `-d DIR` | `--directory DIR` | Working directory | + +### `%pyproject_wheel` + +| Short | Long | Description | +|--------------|-----------------------------|-------------------------------------------| +| `-C SETTING` | `--config-settings SETTING` | Configuration setting for PEP 517 backend | +| `-d DIR` | `--directory DIR` | Working directory | + +### `%pyproject_save_files` + +| Short | Long | Description | +|-------|------------------------|-------------------------------------------------| +| `-l` | `--assert-license` | Fail when no License-File (PEP 639) is found | +| `-L` | `--no-assert-license` | Don't fail on missing License-File (default) | +| `-M` | `--allow-no-modules` | Allow no module globs | + +### `%pyproject_check_import` + +| Short | Long | Description | +|-----------|--------------------|------------------------------------| +| `-e GLOB` | `--exclude GLOB` | Exclude module names matching glob | +| `-t` | `--top-level-only` | Only check top-level modules | + +### `%tox` + +| Short | Long | Description | +|-------------|-------------------|----------------------| +| `-e TOXENV` | `--toxenv TOXENV` | Tox environment to use | + + Limitations ----------- @@ -628,9 +683,9 @@ Deprecated The `%{pyproject_build_lib}` macro is deprecated, don't use it. -The `%pyproject_buildrequires` `-w` option is deprecated, don't use it. +The `%pyproject_buildrequires` `-w`/`--wheel` option is deprecated, don't use it. If the build backend does not support the [prepare-metadata-for-build-wheel hook], -consider using the `-p` flag to read the metadata from the pyproject.toml +consider using the `-p`/`--pyproject-dependencies` flag to read the metadata from the pyproject.toml `[project]` table (as defined in [PEP 621]) instead. diff --git a/macros.pyproject b/macros.pyproject index 373b0ee..dd4cfa3 100644 --- a/macros.pyproject +++ b/macros.pyproject @@ -39,14 +39,19 @@ # %%pyproject_patch_dependency defined in srpm macros -%pyproject_wheel(C:d:) %{expand:\\\ +%pyproject_wheel(-) \ +%{lua:require("fedora.rpm.pyproject_getopt").getopt({ + {short="C", long="config-settings", value=true, separator=","}, + {short="d", long="directory", value=true}, +})}\ +%{expand:\\\ %_set_pytest_addopts -%{-d:pushd "%{-d*}"} +%{?__pyproject_opt_d:pushd "%{__pyproject_opt_d}"} mkdir -p "%{_pyproject_builddir}" %{_pyproject_build_flags} \\\ TMPDIR="%{_pyproject_builddir}" \\\ %{__python3} -Bs %{_rpmconfigdir}/redhat/pyproject_wheel.py %{?**} %{_pyproject_wheeldir} -%{-d:popd} +%{?__pyproject_opt_d:popd} } @@ -133,7 +138,13 @@ fi # https://github.com/rpm-software-management/rpm/issues/1749#issuecomment-1020420616 # Since we support both ways, we pass either 4.19 or 4.18 to the script, so it knows which one to use # Rather than passing the actual version, we let RPM compare the versions, as it is easier done here than in Python -%pyproject_save_files(lLM) %{expand:\\\ +%pyproject_save_files(-) \ +%{lua:require("fedora.rpm.pyproject_getopt").getopt({ + {short="l", long="assert-license"}, + {short="L", long="no-assert-license"}, + {short="M", long="allow-no-modules"}, +})}\ +%{expand:\\\ %{expr:v"0%{?rpmversion}" >= v"4.18.90" ? "RPM_FILES_ESCAPE=4.19" : "RPM_FILES_ESCAPE=4.18" } \\ %{__python3} %{_rpmconfigdir}/redhat/pyproject_save_files.py \\ --output-files "%{pyproject_files}" \\ @@ -147,18 +158,21 @@ fi %{**} } -# -t - Process only top-level modules -# -e - Exclude the module names matching given glob, may be used repeatedly -%pyproject_check_import(e:t) %{expand:\\\ +%pyproject_check_import(-) \ +%{lua:require("fedora.rpm.pyproject_getopt").getopt({ + {short="e", long="exclude", value=true, separator=" -e "}, + {short="t", long="top-level-only"}, +})}\ +%{expand:\\\ if [ ! -f "%{_pyproject_modules}" ]; then echo 'ERROR: %%%%pyproject_check_import only works when %%%%pyproject_save_files is used' >&2 exit 1 fi -%py3_check_import -f "%{_pyproject_modules}" %{?**} +%py3_check_import -f "%{_pyproject_modules}" %{?__pyproject_opt_e:-e %{__pyproject_opt_e}} %{?__pyproject_opt_t:-t} %{?__pyproject_positional_args} } -%_pyproject_check_import_allow_no_modules(e:t) \ +%_pyproject_check_import_allow_no_modules(-) \ if [ -z "$(cat %{_pyproject_modules})" ]; then\ echo "No modules to check found, exiting check"\ else\ @@ -177,51 +191,53 @@ fi ]"\ ] -%pyproject_buildrequires(rRxtNwpe:g:C:d:) %{expand:\\\ +%pyproject_buildrequires(-) \ +%{lua:require("fedora.rpm.pyproject_getopt").getopt({ + {short="r", long="runtime"}, + {short="R", long="no-runtime"}, + {short="x", long="extras", value=true, separator=","}, + {short="t", long="tox"}, + {short="N", long="no-use-build-system"}, + {short="w", long="wheel"}, + {short="p", long="pyproject-dependencies"}, + {short="e", long="toxenv", value=true, separator=","}, + {short="g", long="dependency-groups", value=true, separator=","}, + {short="C", long="config-settings", value=true, separator=","}, + {short="d", long="directory", value=true}, +}, { + {"R", {"r", "x", "e", "t", "w", "p"}}, + {"N", {"r", "x", "e", "t", "w", "p", "C"}}, + {"w", {"p"}}, +})}\ +%{expand:\\\ %_set_pytest_addopts # The default flags expect the package note file to exist # see https://bugzilla.redhat.com/show_bug.cgi?id=2097535 %{?_package_note_flags:%_generate_package_note_file} -%{-d:pushd "%{-d*}" >&2} -%{-R: -%{-r:%{error:The -R and -r options are mutually exclusive}} -%{-x:%{error:The -R and -x options are mutually exclusive}} -%{-e:%{error:The -R and -e options are mutually exclusive}} -%{-t:%{error:The -R and -t options are mutually exclusive}} -%{-w:%{error:The -R and -w options are mutually exclusive}} -%{-p:%{error:The -R and -p options are mutually exclusive}} +%{?__pyproject_opt_d:pushd "%{__pyproject_opt_d}" >&2} +%{?__pyproject_opt_w: +%{!?__pyproject_buildrequires_w_warned:%{warn:The %%pyproject_buildrequires -w/--wheel option is deprecated. +It's not efficient to build the wheel several times during the build. +The option is not scheduled for removal, but packagers should use the -p/--pyproject-dependencies option instead. +}%global __pyproject_buildrequires_w_warned 1} } -%{-N: -%{-r:%{error:The -N and -r options are mutually exclusive}} -%{-x:%{error:The -N and -x options are mutually exclusive}} -%{-e:%{error:The -N and -e options are mutually exclusive}} -%{-t:%{error:The -N and -t options are mutually exclusive}} -%{-w:%{error:The -N and -w options are mutually exclusive}} -%{-p:%{error:The -N and -p options are mutually exclusive}} -%{-C:%{error:The -N and -C options are mutually exclusive}} -%{-g:if [ -f pyproject.toml ]; then +%{?__pyproject_opt_N: +%{?__pyproject_opt_g:if [ -f pyproject.toml ]; then %_pyproject_tomlidep fi} } -%{-w: -%{!?__pyproject_buildrequires_w_warned:%{warn:The %%pyproject_buildrequires -w option is deprecated. -It's not efficient to build the wheel several times during the build. -The option is not scheduled for removal, but packagers should use the -p option instead. -}%global __pyproject_buildrequires_w_warned 1} -%{-p:%{error:The -w and -p options are mutually exclusive}} -} -%{-e:%{expand:%global toxenv %(%{__python3} -s %{_rpmconfigdir}/redhat/pyproject_construct_toxenv.py %{?**})}} +%{?__pyproject_opt_e:%{expand:%global toxenv %{__pyproject_opt_e}}} echo 'pyproject-rpm-macros' # first stdout line matches the implementation in macros.aaa-pyproject-srpm echo 'python%{python3_pkgversion}-devel' echo 'python%{python3_pkgversion}dist(packaging)' -%{!-N:echo 'python%{python3_pkgversion}dist(pip) >= 19' +%{!?__pyproject_opt_N:echo 'python%{python3_pkgversion}dist(pip) >= 19' if [ -f pyproject.toml ]; then %_pyproject_tomlidep elif [ -f setup.py ]; then # Note: If the default requirements change, also change them in the script! echo 'python%{python3_pkgversion}dist(setuptools) >= 40.8' else - echo 'ERROR: Neither pyproject.toml nor setup.py found, consider using %%%%pyproject_buildrequires -N if this is not a Python package.' >&2 + echo 'ERROR: Neither pyproject.toml nor setup.py found, consider using %%%%pyproject_buildrequires -N/--no-use-build-system if this is not a Python package.' >&2 exit 1 fi} # setuptools assumes no pre-existing dist-info @@ -236,18 +252,22 @@ if [ -f %{__python3} ]; then fi # Incomplete .dist-info dir might confuse importlib.metadata rm -rfv *.dist-info/ >&2 -%{-d:popd >&2} +%{?__pyproject_opt_d:popd >&2} } -%tox(e:) %{expand:\\\ +%tox(-) \ +%{lua:require("fedora.rpm.pyproject_getopt").getopt({ + {short="e", long="toxenv", value=true, separator=","}, +})}\ +%{expand:\\\ TOX_TESTENV_PASSENV="${TOX_TESTENV_PASSENV:-*}" \\ %{?py3_test_envvars}%{!?py3_test_envvars:PYTHONDONTWRITEBYTECODE=1 \\ PATH="%{buildroot}%{_bindir}:$PATH" \\ PYTHONPATH="${PYTHONPATH:-%{buildroot}%{python3_sitearch}:%{buildroot}%{python3_sitelib}}" \\ %{?__pytest_addopts:PYTEST_ADDOPTS="${PYTEST_ADDOPTS:-} %{__pytest_addopts}"}} \\ HOSTNAME="rpmbuild" \\ -%{__python3} -m tox --current-env --assert-config -q --recreate -e "%{-e:%{-e*}}%{!-e:%{toxenv}}" %{?*} +%{__python3} -m tox --current-env --assert-config -q --recreate -e "%{?__pyproject_opt_e}%{!?__pyproject_opt_e:%{toxenv}}" %{?__pyproject_positional_args} } diff --git a/pyproject-rpm-macros.spec b/pyproject-rpm-macros.spec index 784cb06..06068fa 100644 --- a/pyproject-rpm-macros.spec +++ b/pyproject-rpm-macros.spec @@ -14,7 +14,7 @@ License: MIT # Increment Y and reset Z when new macros or features are added # Increment Z when this is a bugfix or a cosmetic change # Dropping support for EOL Fedoras is *not* considered a breaking change -Version: 1.21.0 +Version: 1.22.0 Release: 1%{?dist} # Macro files @@ -26,18 +26,21 @@ Source101: pyproject_buildrequires.py Source102: pyproject_save_files.py Source103: pyproject_convert.py Source104: pyproject_preprocess_record.py -Source105: pyproject_construct_toxenv.py Source106: pyproject_requirements_txt.py Source107: pyproject_wheel.py Source108: pyproject_patch_metadata.py Source109: pyproject_dependency_overrides.py +Source110: pyproject_getopt.lua # Tests Source201: test_pyproject_buildrequires.py Source202: test_pyproject_save_files.py Source203: test_pyproject_requirements_txt.py -Source204: compare_mandata.py -Source205: test_dependency_overrides.py +Source204: test_dependency_overrides.py +Source205: test_pyproject_getopt_consistency.py +Source206: test_pyproject_getopt_parser.py +Source207: test_pyproject_getopt.lua +Source208: compare_mandata.py # Test data Source301: pyproject_buildrequires_testcases.yaml @@ -146,13 +149,14 @@ cp -p %{sources} . %install mkdir -p %{buildroot}%{_rpmmacrodir} mkdir -p %{buildroot}%{_rpmconfigdir}/redhat +mkdir -p %{buildroot}%{_rpmluadir}/fedora/rpm install -pm 644 macros.pyproject %{buildroot}%{_rpmmacrodir}/ install -pm 644 macros.aaa-pyproject-srpm %{buildroot}%{_rpmmacrodir}/ +install -pm 644 pyproject_getopt.lua %{buildroot}%{_rpmluadir}/fedora/rpm/ install -pm 644 pyproject_buildrequires.py %{buildroot}%{_rpmconfigdir}/redhat/ install -pm 644 pyproject_convert.py %{buildroot}%{_rpmconfigdir}/redhat/ install -pm 644 pyproject_save_files.py %{buildroot}%{_rpmconfigdir}/redhat/ install -pm 644 pyproject_preprocess_record.py %{buildroot}%{_rpmconfigdir}/redhat/ -install -pm 644 pyproject_construct_toxenv.py %{buildroot}%{_rpmconfigdir}/redhat/ install -pm 644 pyproject_requirements_txt.py %{buildroot}%{_rpmconfigdir}/redhat/ install -pm 644 pyproject_wheel.py %{buildroot}%{_rpmconfigdir}/redhat/ install -pm 644 pyproject_patch_metadata.py %{buildroot}%{_rpmconfigdir}/redhat/ @@ -183,11 +187,11 @@ export HOSTNAME="rpmbuild" # to speedup tox in network-less mock, see rhbz#1856 %{_rpmconfigdir}/redhat/pyproject_convert.py %{_rpmconfigdir}/redhat/pyproject_save_files.py %{_rpmconfigdir}/redhat/pyproject_preprocess_record.py -%{_rpmconfigdir}/redhat/pyproject_construct_toxenv.py %{_rpmconfigdir}/redhat/pyproject_requirements_txt.py %{_rpmconfigdir}/redhat/pyproject_wheel.py %{_rpmconfigdir}/redhat/pyproject_patch_metadata.py %{_rpmconfigdir}/redhat/pyproject_dependency_overrides.py +%{_rpmluadir}/fedora/rpm/pyproject_getopt.lua %doc README.md %license LICENSE @@ -198,6 +202,10 @@ export HOSTNAME="rpmbuild" # to speedup tox in network-less mock, see rhbz#1856 %changelog +* Thu May 07 2026 Miro Hrončok - 1.22.0-1 +- Add long option support for all public parametric macros +- E.g. %%pyproject_buildrequires --no-runtime is equivalent to %%pyproject_buildrequires -R + * Wed Apr 29 2026 Tomáš Hrnčiar - 1.21.0-1 - Implement extras validation - %%pyproject_buildrequires: validates if extras exist in upstream metadata, otherwise ValueError is raised on Fedora >=45 and RHEL >=11. It will emit a warning instead on older releases. diff --git a/pyproject_buildrequires.py b/pyproject_buildrequires.py index 5501ee8..61b31bd 100644 --- a/pyproject_buildrequires.py +++ b/pyproject_buildrequires.py @@ -509,8 +509,8 @@ def generate_run_requirements_pyproject(requirements): requirements.metadata_extras.append(canonicalize_name(extra)) -def generate_run_requirements(backend, requirements, *, build_wheel, read_pyproject_dependencies, wheeldir): - if read_pyproject_dependencies: +def generate_run_requirements(backend, requirements, *, build_wheel, pyproject_dependencies, wheeldir): + if pyproject_dependencies: generate_run_requirements_pyproject(requirements) elif build_wheel: generate_run_requirements_wheel(backend, requirements, wheeldir) @@ -679,7 +679,7 @@ def generate_requires( *, include_runtime=False, build_wheel=False, wheeldir=None, toxenv=None, extras=None, dependency_groups=None, get_installed_version=importlib.metadata.version, # for dep injection generate_extras=False, python3_pkgversion="3", requirement_files=None, use_build_system=True, - read_pyproject_dependencies=False, + pyproject_dependencies=False, output, config_settings=None, dependency_overrides=None, ): """Generate the BuildRequires for the project in the current directory @@ -698,7 +698,7 @@ def generate_requires( dependency_groups = dependency_groups or [] try: - if (include_runtime or toxenv or read_pyproject_dependencies) and not use_build_system: + if (include_runtime or toxenv or pyproject_dependencies) and not use_build_system: raise ValueError('-N option cannot be used in combination with -r, -e, -t, -x, -p options') if requirement_files: for req_file in requirement_files: @@ -712,7 +712,7 @@ def generate_requires( generate_build_requirements(backend, requirements) if include_runtime or toxenv: generate_run_requirements(backend, requirements, build_wheel=build_wheel, - read_pyproject_dependencies=read_pyproject_dependencies, wheeldir=wheeldir) + pyproject_dependencies=pyproject_dependencies, wheeldir=wheeldir) if toxenv: generate_tox_requirements(toxenv, requirements) dependency_groups.extend(tox_dependency_groups(toxenv)) @@ -737,7 +737,7 @@ def generate_requires( output.write_text(os.linesep.join(requirements.output_lines) + os.linesep) -def main(argv): +def argparser(): parser = argparse.ArgumentParser( description='Generate BuildRequires for a Python project.', prog='%pyproject_buildrequires', @@ -793,7 +793,7 @@ def main(argv): '(useful for build backends without the prepare_metadata_for_build_wheel hook, deprecated)'), ) parser.add_argument( - '-p', '--read-pyproject-dependencies', action='store_true', default=False, + '-p', '--pyproject-dependencies', action='store_true', default=False, help=('Generate dependencies from [project] table of pyproject.toml ' 'instead of calling prepare_metadata_for_build_wheel hook)'), ) @@ -811,17 +811,21 @@ def main(argv): help=('Add buildrequires from file'), ) parser.add_argument( - '-C', + '-C', '--config-settings', dest='config_settings', action='append', help='Configuration settings to pass to the PEP 517 backend', ) - parser.add_argument('-d', help=argparse.SUPPRESS) # processed by RPM macro parser.add_argument( '--dep-overrides-file', type=pathlib.Path, default=None, help=argparse.SUPPRESS, ) + parser.add_argument('-d', '--directory', help=argparse.SUPPRESS) # processed by RPM macro + return parser + +def main(argv): + parser = argparser() args = parser.parse_args(argv) if not args.use_build_system: @@ -859,7 +863,7 @@ def main(argv): python3_pkgversion=args.python3_pkgversion, requirement_files=args.requirement_files, use_build_system=args.use_build_system, - read_pyproject_dependencies=args.read_pyproject_dependencies, + pyproject_dependencies=args.pyproject_dependencies, output=args.output, config_settings=parse_config_settings_args(args.config_settings), dependency_overrides=dependency_overrides, diff --git a/pyproject_buildrequires_testcases.yaml b/pyproject_buildrequires_testcases.yaml index 68148fd..c4c5fd1 100644 --- a/pyproject_buildrequires_testcases.yaml +++ b/pyproject_buildrequires_testcases.yaml @@ -1384,7 +1384,7 @@ config_settings: pyproject.toml with runtime dependencies read from it: skipif: not SETUPTOOLS_60 - read_pyproject_dependencies: true + pyproject_dependencies: true installed: setuptools: 50 wheel: 1 @@ -1412,7 +1412,7 @@ pyproject.toml with runtime dependencies read from it: pyproject.toml with extras - only runtime dependencies read from it: skipif: not SETUPTOOLS_60 - read_pyproject_dependencies: true + pyproject_dependencies: true installed: setuptools: 50 wheel: 1 @@ -1430,7 +1430,7 @@ pyproject.toml with extras - only runtime dependencies read from it: pyproject.toml with runtime dependencies and partially selected extras read from it: skipif: not SETUPTOOLS_60 - read_pyproject_dependencies: true + pyproject_dependencies: true installed: setuptools: 50 wheel: 1 @@ -1454,7 +1454,7 @@ pyproject.toml with runtime dependencies and partially selected extras read from pyproject.toml with runtime dependencies and all extras read from it: skipif: not SETUPTOOLS_60 - read_pyproject_dependencies: true + pyproject_dependencies: true installed: setuptools: 50 wheel: 1 @@ -1483,7 +1483,7 @@ pyproject.toml with runtime dependencies and all extras read from it: pyproject.toml without dependencies: skipif: not SETUPTOOLS_60 - read_pyproject_dependencies: true + pyproject_dependencies: true installed: setuptools: 50 wheel: 1 @@ -1505,7 +1505,7 @@ pyproject.toml without dependencies: pyproject.toml without project table: skipif: not SETUPTOOLS_60 - read_pyproject_dependencies: true + pyproject_dependencies: true installed: setuptools: 50 wheel: 1 @@ -1516,7 +1516,7 @@ pyproject.toml without project table: except: ValueError no pyproject.toml: - read_pyproject_dependencies: true + pyproject_dependencies: true installed: setuptools: 50 wheel: 1 @@ -1524,7 +1524,7 @@ no pyproject.toml: pyproject.toml with dynamic dependencies: skipif: not SETUPTOOLS_60 - read_pyproject_dependencies: true + pyproject_dependencies: true installed: setuptools: 50 wheel: 1 @@ -1546,7 +1546,7 @@ pyproject.toml with dynamic dependencies: pyproject.toml with dynamic optional dependencies: skipif: not SETUPTOOLS_60 - read_pyproject_dependencies: true + pyproject_dependencies: true installed: setuptools: 50 wheel: 1 @@ -1569,7 +1569,7 @@ pyproject.toml with dynamic optional dependencies: pyproject.toml with dynamic table and no deps: skipif: not SETUPTOOLS_60 - read_pyproject_dependencies: true + pyproject_dependencies: true installed: setuptools: 50 wheel: 1 @@ -1596,7 +1596,7 @@ pyproject.toml with dynamic table and no deps: pyproject.toml with self-referencing extras: skipif: not SETUPTOOLS_60 - read_pyproject_dependencies: true + pyproject_dependencies: true installed: setuptools: 50 wheel: 1 diff --git a/pyproject_construct_toxenv.py b/pyproject_construct_toxenv.py deleted file mode 100644 index a2a5f96..0000000 --- a/pyproject_construct_toxenv.py +++ /dev/null @@ -1,15 +0,0 @@ -import argparse -import sys - - -def main(argv): - parser = argparse.ArgumentParser( - description='Parse -e arguments instead of RPM getopt.' - ) - parser.add_argument('-e', '--toxenv', action='append') - args, _ = parser.parse_known_args(argv) - return ','.join(args.toxenv) - - -if __name__ == '__main__': - print(main(sys.argv[1:])) diff --git a/pyproject_getopt.lua b/pyproject_getopt.lua new file mode 100644 index 0000000..569415e --- /dev/null +++ b/pyproject_getopt.lua @@ -0,0 +1,256 @@ +local M = {} + + +local function error_out(macro_name, message) + rpm.expand("%{error:%%" .. macro_name .. ": " .. message .. "}") +end + + +local function option_repr(spec) + return "-" .. spec.short .. "/--" .. spec.long +end + + +local function error_mutually_exclusive(macro_name, a, b) + error_out(macro_name, + option_repr(a) .. " and " .. option_repr(b) .. + " are mutually exclusive") +end + + +-- Append a value to found[short]. Errors if repeated without a separator. +local function record_value(macro_name, spec, label, val, found) + if found[spec.short] then + if not spec.separator then + error_out(macro_name, "option " .. label .. " cannot be repeated") + return false + end + else + found[spec.short] = {} + end + found[spec.short][#found[spec.short] + 1] = val + return true +end + + +-- Build a token table from RPM macro arguments (%1, %2, ..., %{%#}). +-- This preserves %{quote:...} quoting, unlike splitting %** on whitespace. +-- Can be replaced by the native RPM Lua args table when we no longer support c9s. +function M.rpm_args() + local args = {} + local nargs = tonumber(rpm.expand("%#")) + for n = 1, nargs do + args[n] = rpm.expand("%" .. n) + end + return args +end + + +local function build_lookup(opt_spec) + local by_short = {} + local by_long = {} + for _, spec in ipairs(opt_spec) do + by_short[spec.short] = spec + by_long[spec.long] = spec + end + return by_short, by_long +end + + +-- Store an option's value. If val is nil, consume the next token. +-- Returns the updated token index, or nil on error. +local function consume_value(macro_name, spec, label, val, tokens, i, found) + if val == nil then + i = i + 1 + if i > #tokens then + error_out(macro_name, "option " .. label .. " requires a value") + return nil + end + val = tokens[i] + end + if not record_value(macro_name, spec, label, val, found) then + return nil + end + return i +end + + +-- Process a --long-option token. Handles --name, --name=val, and --name val. +local function handle_long_option(macro_name, token, tokens, i, by_long, found) + local rest = token:sub(3) + local eq = rest:find("=", 1, true) + local name, val + if eq then + name = rest:sub(1, eq - 1) + val = rest:sub(eq + 1) + else + name = rest + end + + local spec = by_long[name] + if not spec then + error_out(macro_name, "unknown option: --" .. name) + return nil + end + + if spec.value then + return consume_value(macro_name, spec, "--" .. name, val, tokens, i, found) + else + if val ~= nil then + error_out(macro_name, "option --" .. name .. " does not take a value") + return nil + end + found[spec.short] = true + return i + end +end + + +-- Process a short option token. Handles -x, -xval, -x val, and bundled -xyz. +local function handle_short_options(macro_name, token, tokens, i, by_short, found) + local j = 2 + while j <= #token do + local ch = token:sub(j, j) + local spec = by_short[ch] + if not spec then + error_out(macro_name, "unknown option: -" .. ch) + return nil + end + + if spec.value then + local val + if j < #token then + val = token:sub(j + 1) + j = #token + end + i = consume_value(macro_name, spec, "-" .. ch, val, tokens, i, found) + if not i then return nil end + else + found[spec.short] = true + end + j = j + 1 + end + + return i +end + + +-- Parse a token list into found options and positional arguments. +-- Returns {found={short_char=true_or_values_table}, positional={...}}, or nil on error. +local function parse(macro_name, tokens, by_short, by_long) + local found = {} + local positional = {} + local i = 1 + + while i <= #tokens do + local token = tokens[i] + + if token == "--" then + for j = i + 1, #tokens do + positional[#positional + 1] = tokens[j] + end + break + elseif token:sub(1, 2) == "--" then + i = handle_long_option(macro_name, token, tokens, i, by_long, found) + if not i then return nil end + elseif token:sub(1, 1) == "-" and #token > 1 then + i = handle_short_options(macro_name, token, tokens, i, by_short, found) + if not i then return nil end + else + positional[#positional + 1] = token + end + + i = i + 1 + end + + return {found = found, positional = positional} +end + + +-- Verify no mutually exclusive options were used together. Returns false on violation. +local function check_exclusions(macro_name, found, by_short, exclusion_rules) + for _, rule in ipairs(exclusion_rules) do + local opt = rule[1] + local conflicts = rule[2] + if found[opt] then + for _, c in ipairs(conflicts) do + if found[c] then + error_mutually_exclusive(macro_name, by_short[opt], by_short[c]) + return false + end + end + end + end + return true +end + + +local function cleanup_macros(opt_spec) + for _, spec in ipairs(opt_spec) do + rpm.undefine("__pyproject_opt_" .. spec.short) + end +end + + +-- On RPM 4.20+, %{quote:} is transparent (no \x1f delimiters in expanded output). +-- On older RPM, %{quote:} leaks \x1f into shell commands, so we skip quoting there. +M._use_quote = rpm.vercmp(rpm.expand("0%{?rpmversion}"), "4.19.90") >= 0 + +-- Define %__pyproject_opt_{short} for each found option. +-- Flags get %{nil} (defined but empty), value options get each individual +-- value joined by separator. +-- On RPM 4.20+ empty values or values with spaces are wrapped in %{quote:} before joining. +-- On older RPM, empty values are represented as %{nil}, values with spaces are verbatim. +local function define_macros(found, opt_spec) + for _, spec in ipairs(opt_spec) do + if found[spec.short] then + if spec.value then + local parts = {} + for i, v in ipairs(found[spec.short]) do + if M._use_quote and (v == "" or v:find("%s")) then + parts[i] = "%{quote:" .. v .. "}" + elseif v == "" then + parts[i] = "%{nil}" + else + parts[i] = v + end + end + rpm.define("__pyproject_opt_" .. spec.short .. " " .. table.concat(parts, spec.separator)) + else + rpm.define("__pyproject_opt_" .. spec.short .. " %{nil}") + end + end + end +end + + +-- Parse and validate macro options, define %__pyproject_opt_* and %__pyproject_positional_args. +-- opt_spec: list of {short=, long=, value=bool, separator=string} tables. +-- value: option takes an argument. +-- separator: allow repeats and join values with this string. Without it, repeats with values are an error. +-- exclusion_rules: optional list of {short_char, {conflicting_short_chars...}} pairs. +-- tokens and macro_name default to the current RPM macro's arguments and name. +function M.getopt(opt_spec, exclusion_rules, tokens, macro_name) + tokens = tokens or M.rpm_args() + macro_name = macro_name or rpm.expand("%0") + local by_short, by_long = build_lookup(opt_spec) + cleanup_macros(opt_spec) + rpm.undefine("__pyproject_positional_args") + + local result = parse(macro_name, tokens, by_short, by_long) + if not result then return end + + if exclusion_rules then + if not check_exclusions(macro_name, result.found, by_short, exclusion_rules) then + return + end + end + + define_macros(result.found, opt_spec) + + if #result.positional > 0 then + rpm.define("__pyproject_positional_args " .. table.concat(result.positional, " ")) + end +end + +return M diff --git a/pyproject_wheel.py b/pyproject_wheel.py index 1a32f17..55e35e6 100644 --- a/pyproject_wheel.py +++ b/pyproject_wheel.py @@ -60,19 +60,24 @@ def build_wheel(*, wheeldir, stdout=None, config_settings=None): return cp.returncode -def parse_args(argv=None): +def argparser(): parser = argparse.ArgumentParser(prog='%pyproject_wheel') parser.add_argument('wheeldir', help=argparse.SUPPRESS) parser.add_argument( - '-C', + '-C', '--config-settings', dest='config_settings', action='append', help='Configuration settings to pass to the PEP 517 backend', ) - parser.add_argument('-d', help=argparse.SUPPRESS) # processed by RPM macro + parser.add_argument('-d', '--directory', help=argparse.SUPPRESS) # processed by RPM macro + return parser + + +def parse_args(argv=None): + parser = argparser() args = parser.parse_args(argv) args.config_settings = parse_config_settings_args(args.config_settings) - del args.d + del args.directory return args diff --git a/test_pyproject_buildrequires.py b/test_pyproject_buildrequires.py index a697325..622e598 100644 --- a/test_pyproject_buildrequires.py +++ b/test_pyproject_buildrequires.py @@ -76,7 +76,7 @@ def test_data(case_name, capfd, tmp_path, monkeypatch): requirement_files = case.get('requirement_files', []) requirement_files = [open(f) for f in requirement_files] use_build_system = case.get('use_build_system', True) - read_pyproject_dependencies = case.get('read_pyproject_dependencies', False) + pyproject_dependencies = case.get('pyproject_dependencies', False) try: generate_requires( get_installed_version=get_installed_version, @@ -89,7 +89,7 @@ def test_data(case_name, capfd, tmp_path, monkeypatch): generate_extras=case.get('generate_extras', False), requirement_files=requirement_files, use_build_system=use_build_system, - read_pyproject_dependencies=read_pyproject_dependencies, + pyproject_dependencies=pyproject_dependencies, output=output, config_settings=case.get('config_settings'), dependency_overrides=case.get('dependency_overrides', []), diff --git a/test_pyproject_getopt.lua b/test_pyproject_getopt.lua new file mode 100644 index 0000000..5572a30 --- /dev/null +++ b/test_pyproject_getopt.lua @@ -0,0 +1,460 @@ +local pg = require("pyproject_getopt") + +local M = {} + + +-- Helpers + +local function assert_defined(name) + assert( + rpm.expand("%{defined __pyproject_opt_" .. name .. "}") == "1", + "expected __pyproject_opt_" .. name .. " to be defined" + ) +end + +local function assert_undefined(name) + assert( + rpm.expand("%{undefined __pyproject_opt_" .. name .. "}") == "1", + "expected __pyproject_opt_" .. name .. " to be undefined" + ) +end + +local function assert_value(name, expected) + local actual = rpm.expand("%{?__pyproject_opt_" .. name .. "}") + assert( + actual == expected, + "expected __pyproject_opt_" .. name .. "=" .. expected .. ", got " .. actual + ) +end + +local function assert_positional(expected) + local actual = rpm.expand("%{?__pyproject_positional_args}") + assert( + actual == expected, + "expected positional=" .. expected .. ", got " .. actual + ) +end + +local function assert_errors(fn) + local ok = pcall(fn) + assert(not ok, "expected an error but call succeeded") +end + +local function skip(reason) + print("SKIP: " .. reason) +end + + +-- Specs +-- The specs here were copied from the implementation of the macros +-- when this test was added. But no need to keep them in sync, +-- as long as we test what needs to be tested. + +local SAVE_FILES_SPEC = { + {short="l", long="assert-license"}, + {short="L", long="no-assert-license"}, + {short="M", long="allow-no-modules"}, +} + +local WHEEL_SPEC = { + {short="C", long="config-settings", value=true, separator=","}, + {short="d", long="directory", value=true}, +} + +local BUILDREQUIRES_SPEC = { + {short="r", long="runtime"}, + {short="R", long="no-runtime"}, + {short="x", long="extras", value=true, separator=","}, + {short="t", long="tox"}, + {short="N", long="no-use-build-system"}, + {short="w", long="wheel"}, + {short="p", long="pyproject-dependencies"}, + {short="e", long="toxenv", value=true, separator=","}, + {short="g", long="dependency-groups", value=true, separator=","}, + {short="C", long="config-settings", value=true, separator=","}, + {short="d", long="directory", value=true}, +} + +local BUILDREQUIRES_EXCLUSIONS = { + {"R", {"r", "x", "e", "t", "w", "p"}}, + {"N", {"r", "x", "e", "t", "w", "p", "C"}}, + {"w", {"p"}}, +} + +local CHECK_IMPORT_SPEC = { + {short="e", long="exclude", value=true, separator=" -e "}, + {short="t", long="top-level-only"}, +} + +local TOX_SPEC = { + {short="e", long="toxenv", value=true, separator=","}, +} + + +-- Short flags + +function M.test_single_flag() + pg.getopt(SAVE_FILES_SPEC, nil, {"-l"}, "test") + assert_defined("l") + assert_undefined("L") + assert_undefined("M") +end + +function M.test_multiple_flags() + pg.getopt(SAVE_FILES_SPEC, nil, {"-l", "-M"}, "test") + assert_defined("l") + assert_defined("M") + assert_undefined("L") +end + +function M.test_repeated_flag() + pg.getopt(SAVE_FILES_SPEC, nil, {"-l", "-l"}, "test") + assert_defined("l") +end + +function M.test_bundled_flags() + pg.getopt(SAVE_FILES_SPEC, nil, {"-lM"}, "test") + assert_defined("l") + assert_defined("M") +end + + +-- Long flags + +function M.test_single_long_flag() + pg.getopt(SAVE_FILES_SPEC, nil, {"--assert-license"}, "test") + assert_defined("l") + assert_undefined("L") +end + +function M.test_multiple_long_flags() + pg.getopt(SAVE_FILES_SPEC, nil, {"--assert-license", "--allow-no-modules"}, "test") + assert_defined("l") + assert_defined("M") +end + + +-- Value options + +function M.test_short_with_value() + pg.getopt(WHEEL_SPEC, nil, {"-d", "foo"}, "test") + assert_value("d", "foo") +end + +function M.test_short_value_attached() + pg.getopt(WHEEL_SPEC, nil, {"-dfoo"}, "test") + assert_value("d", "foo") +end + +function M.test_long_with_value() + pg.getopt(WHEEL_SPEC, nil, {"--directory", "foo"}, "test") + assert_value("d", "foo") +end + +function M.test_long_with_equals() + pg.getopt(WHEEL_SPEC, nil, {"--directory=foo"}, "test") + assert_value("d", "foo") +end + +function M.test_value_with_spaces() + pg.getopt(WHEEL_SPEC, nil, {"-d", "Path With Spaces"}, "test") + assert_value("d", "Path With Spaces") +end + +function M.test_long_value_with_spaces() + pg.getopt(WHEEL_SPEC, nil, {"--directory", "Path With Spaces"}, "test") + assert_value("d", "Path With Spaces") +end + +function M.test_empty_value() + pg.getopt(WHEEL_SPEC, nil, {"-d", ""}, "test") + assert_defined("d") + assert_value("d", "") +end + +function M.test_empty_value_stays_single_arg() + if not pg._use_quote then return skip("%{quote:} not transparent on this RPM") end + pg.getopt(WHEEL_SPEC, nil, {"-d", ""}, "test") + rpm.define([[_test_countargs(-) %#]]) + local nargs = rpm.expand("%_test_countargs %{__pyproject_opt_d}") + assert(nargs == "1", + "expected empty value to be 1 arg, got " .. nargs) +end + +function M.test_value_with_spaces_stays_single_arg() + if not pg._use_quote then return skip("%{quote:} not transparent on this RPM") end + pg.getopt(WHEEL_SPEC, nil, {"-d", "Path With Spaces"}, "test") + -- Define a macro that expands to its argument count + rpm.define([[_test_countargs(-) %#]]) + -- Pass the stored value to another macro — it must stay as 1 argument + local nargs = rpm.expand("%_test_countargs %{__pyproject_opt_d}") + assert(nargs == "1", + "expected value with spaces to be 1 arg, got " .. nargs) +end + +function M.test_value_looking_like_option() + pg.getopt(WHEEL_SPEC, nil, {"--config-settings", "--with-dashes=1"}, "test") + assert_defined("C") +end + +function M.test_short_value_attached_looking_like_option() + pg.getopt(WHEEL_SPEC, nil, {"-C--with-dashes=1"}, "test") + assert_defined("C") +end + + +-- Repeated options + +function M.test_repeated_parse_option_comma_joins() + pg.getopt(TOX_SPEC, nil, {"-e", "env1", "-e", "env2"}, "test") + assert_value("e", "env1,env2") +end + +function M.test_repeated_long_parse_option() + pg.getopt(TOX_SPEC, nil, {"--toxenv", "env1", "--toxenv", "env2"}, "test") + assert_value("e", "env1,env2") +end + +function M.test_repeated_short_and_long_parse_option() + pg.getopt(TOX_SPEC, nil, {"-e", "env1", "--toxenv", "env2"}, "test") + assert_value("e", "env1,env2") +end + +function M.test_repeated_long_and_short_parse_option() + pg.getopt(TOX_SPEC, nil, {"--toxenv", "env1", "-e", "env2"}, "test") + assert_value("e", "env1,env2") +end + +function M.test_repeated_value_option_comma_joins() + pg.getopt(WHEEL_SPEC, nil, {"-C", "a", "-C", "b"}, "test") + assert_defined("C") + assert_value("C", "a,b") +end + + +-- Positional args + +function M.test_positional_only() + pg.getopt(SAVE_FILES_SPEC, nil, {"foo", "bar"}, "test") + assert_positional("foo bar") +end + +function M.test_mixed_options_and_positional() + pg.getopt(SAVE_FILES_SPEC, nil, {"-l", "foo", "bar"}, "test") + assert_defined("l") + assert_positional("foo bar") +end + +function M.test_double_dash_stops_parsing() + pg.getopt(TOX_SPEC, nil, {"-e", "env1", "--", "--not-an-opt"}, "test") + assert_value("e", "env1") + assert_positional("--not-an-opt") +end + +function M.test_double_dash_repeated() + pg.getopt(TOX_SPEC, nil, {"-e", "env1", "--", "a", "--", "-b", "--", "--c"}, "test") + assert_value("e", "env1") + assert_positional("a -- -b -- --c") +end + +function M.test_no_arguments() + pg.getopt(SAVE_FILES_SPEC, nil, {}, "test") + assert_positional("") + assert_undefined("l") +end + +function M.test_positional_with_spaces() + pg.getopt(SAVE_FILES_SPEC, nil, {"module with spaces", "other"}, "test") + assert_positional("module with spaces other") +end + + +-- Errors + +function M.test_unknown_short_option() + assert_errors(function() pg.getopt(SAVE_FILES_SPEC, nil, {"-z"}, "test") end) +end + +function M.test_unknown_long_option() + assert_errors(function() pg.getopt(SAVE_FILES_SPEC, nil, {"--bogus"}, "test") end) +end + +function M.test_missing_value_short() + assert_errors(function() pg.getopt(WHEEL_SPEC, nil, {"-d"}, "test") end) +end + +function M.test_missing_value_long() + assert_errors(function() pg.getopt(WHEEL_SPEC, nil, {"--directory"}, "test") end) +end + +function M.test_flag_given_value() + assert_errors(function() pg.getopt(SAVE_FILES_SPEC, nil, {"--assert-license=foo"}, "test") end) +end + + +-- Mutual exclusions + +function M.test_R_and_x() + assert_errors(function() + pg.getopt(BUILDREQUIRES_SPEC, BUILDREQUIRES_EXCLUSIONS, {"-R", "-x", "testing"}, "test") + end) +end + +function M.test_long_form_exclusion() + assert_errors(function() + pg.getopt(BUILDREQUIRES_SPEC, BUILDREQUIRES_EXCLUSIONS, {"--no-runtime", "--extras", "testing"}, "test") + end) +end + +function M.test_N_and_C() + assert_errors(function() + pg.getopt(BUILDREQUIRES_SPEC, BUILDREQUIRES_EXCLUSIONS, {"-N", "-C", "foo"}, "test") + end) +end + +function M.test_w_and_p() + assert_errors(function() + pg.getopt(BUILDREQUIRES_SPEC, BUILDREQUIRES_EXCLUSIONS, {"--wheel", "--pyproject-dependencies"}, "test") + end) +end + +function M.test_non_conflicting_passes() + pg.getopt(BUILDREQUIRES_SPEC, BUILDREQUIRES_EXCLUSIONS, {"-R", "-g", "tests"}, "test") + assert_defined("R") + assert_defined("g") +end + + +-- Cleanup + +function M.test_options_do_not_leak_between_calls() + local spec = { + {short="d", long="directory", value=true}, + {short="C", long="config-settings", value=true, separator=","}, + } + pg.getopt(spec, nil, {"-d", "foo", "-C", "bar"}, "test") + assert_value("d", "foo") + assert_defined("C") + pg.getopt(spec, nil, {"-C", "baz"}, "test") + assert_undefined("d") + assert_defined("C") +end + + +-- Separator + +function M.test_comma_separator() + pg.getopt(TOX_SPEC, nil, {"-e", "env1", "-e", "env2"}, "test") + assert_value("e", "env1,env2") +end + +function M.test_custom_separator() + pg.getopt(CHECK_IMPORT_SPEC, nil, {"--exclude", "a", "--exclude", "b"}, "test") + assert_value("e", "a -e b") +end + +function M.test_repeated_values_with_spaces_stay_separate_args() + if not pg._use_quote then return skip("%{quote:} not transparent on this RPM") end + pg.getopt(CHECK_IMPORT_SPEC, nil, {"--exclude", "a b", "--exclude", "c"}, "test") + -- Each value is quoted individually, separator splits them: + -- stored as: %{quote:a b} -e c + -- When passed to another macro as "-e %{__pyproject_opt_e}", + -- it should produce 4 args: -e, "a b", -e, c + rpm.define([[_test_countargs(-) %#]]) + local nargs = rpm.expand("%_test_countargs -e %{__pyproject_opt_e}") + assert(nargs == "4", + "expected 4 args (-e, 'a b', -e, c), got " .. nargs) +end + +function M.test_single_value_no_separator() + pg.getopt(WHEEL_SPEC, nil, {"-d", "foo"}, "test") + assert_value("d", "foo") +end + +function M.test_repeated_without_separator_errors() + assert_errors(function() + pg.getopt(WHEEL_SPEC, nil, {"-d", "foo", "-d", "bar"}, "test") + end) +end + +function M.test_repeated_long_without_separator_errors() + assert_errors(function() + pg.getopt(WHEEL_SPEC, nil, {"--directory", "foo", "--directory", "bar"}, "test") + end) +end + + +-- Raw error functions (without pcall) for Python-side stderr checking. +-- These are not discovered by list() since they don't start with test_. + +function M.raw_unknown_short_option() + pg.getopt(SAVE_FILES_SPEC, nil, {"-z"}, "test") +end + +function M.raw_unknown_long_option() + pg.getopt(SAVE_FILES_SPEC, nil, {"--bogus"}, "test") +end + +function M.raw_missing_value_short() + pg.getopt(WHEEL_SPEC, nil, {"-d"}, "test") +end + +function M.raw_flag_given_value() + pg.getopt(SAVE_FILES_SPEC, nil, {"--assert-license=foo"}, "test") +end + +function M.raw_R_and_x() + pg.getopt(BUILDREQUIRES_SPEC, BUILDREQUIRES_EXCLUSIONS, {"-R", "-x", "testing"}, "test") +end + +function M.raw_repeated_without_separator() + pg.getopt(WHEEL_SPEC, nil, {"-d", "foo", "-d", "bar"}, "test") +end + + +-- RPM macro integration (uses rpm.define to create a real parametric macro) + +local _MACRO_DEF = [[_test_macro(-) %{lua:require("pyproject_getopt").getopt({{short="d", long="directory", value=true}})}]] + +function M.test_macro_parses_short_option() + rpm.define(_MACRO_DEF) + rpm.expand("%_test_macro -d hello") + assert_value("d", "hello") +end + +function M.test_macro_parses_long_option() + rpm.define(_MACRO_DEF) + rpm.expand("%_test_macro --directory hello") + assert_value("d", "hello") +end + +function M.test_macro_preserves_quoted_spaces() + rpm.define(_MACRO_DEF) + rpm.expand("%_test_macro -d %{quote:Path With Spaces}") + assert_value("d", "Path With Spaces") +end + +function M.test_macro_rejects_unknown_option() + rpm.define(_MACRO_DEF) + assert_errors(function() rpm.expand("%_test_macro --bogus") end) +end + +function M.raw_macro_rejects_unknown_option() + rpm.define(_MACRO_DEF) + rpm.expand("%_test_macro --bogus") +end + + +-- List all tests for pytest discovery +function M.list() + local names = {} + for name in pairs(M) do + if type(M[name]) == "function" and name:sub(1, 5) == "test_" then + names[#names + 1] = name + end + end + table.sort(names) + return names +end + +return M diff --git a/test_pyproject_getopt_consistency.py b/test_pyproject_getopt_consistency.py new file mode 100644 index 0000000..b4c5af9 --- /dev/null +++ b/test_pyproject_getopt_consistency.py @@ -0,0 +1,167 @@ +"""Assert that short/long option mappings are consistent across +macros.pyproject, Python argparse scripts, and README.md.""" + +import argparse +import importlib +import re +from pathlib import Path + +import pytest + +BASEDIR = Path(__file__).parent + +# Macros with corresponding Python scripts +MACROS_PYTHON = ( + "pyproject_buildrequires", + "pyproject_save_files", + "pyproject_wheel", +) + +# Macros that appear in the README reference table +MACROS_README = ( + "pyproject_buildrequires", + "pyproject_check_import", + "pyproject_save_files", + "pyproject_wheel", + "tox", +) + + +def parse_macros_file(): + """Extract {macro_name: set((short, long), ...)} from macros.pyproject.""" + text = (BASEDIR / "macros.pyproject").read_text() + result = {} + + # Match .getopt({ specs }) + # The macro name comes from the %macro_name(-) definition line above the call + prev_end = 0 + for m in re.finditer(r"\.getopt\(", text): + start = m.end() + # Find the macro name from the nearest preceding %name(-) line + preceding = text[prev_end:m.start()] + macro_name = re.findall(r"^%(\w+)\(-\)", preceding, re.MULTILINE)[-1] + prev_end = m.end() + # Find the opt_spec table (first argument) + brace_start = text.index("{", start) + depth = 0 + pos = brace_start + while pos < len(text): + if text[pos] == "{": + depth += 1 + elif text[pos] == "}": + depth -= 1 + if depth == 0: + break + pos += 1 + spec_text = text[brace_start : pos + 1] + opts = set() + for opt_m in re.finditer( + r'short\s*=\s*"([^"]+)"\s*,\s*long\s*=\s*"([^"]+)"', spec_text + ): + opts.add((opt_m.group(1), opt_m.group(2))) + result[macro_name] = opts + + return result + + +def parse_python_script(module_name): + """Extract set((short, long), ...) from a module's argparser(). + + Only returns options that have both a short (-X) and long (--Y) form, + which are the user-facing options. + """ + module = importlib.import_module(module_name) + parser = module.argparser() + opts = set() + for action in parser._actions: # this is quite stable private API + if isinstance(action, argparse._HelpAction): + continue + strings = action.option_strings + if len(strings) == 2 and strings[0].startswith("-") and not strings[0].startswith("--"): + short = strings[0][1:] # strip the - + long = strings[1][2:] # strip the -- + opts.add((short, long)) + return opts + + +def parse_readme(): + """Extract {macro_name: set((short, long), ...)} from README tables.""" + text = (BASEDIR / "README.md").read_text() + result = {} + + current_macro = None + in_table = False + + for line in text.splitlines(): + # Detect ### `%macro_name` headings + if heading_m := re.match(r"^### `%(\w+)`", line): + current_macro = heading_m.group(1) + in_table = False + continue + + if current_macro is None: + continue + + # Detect table rows (skip header and separator) + if re.match(r"^\|[-\s|]+\|$", line): + in_table = True + continue + + if not line.startswith("|"): + if in_table: + # Left the table + current_macro = None + in_table = False + continue + + if not in_table: + # This is the header row + if "Short" in line and "Long" in line: + in_table = False # next line will be separator + continue + + # Parse table data row: | `-x EXTRAS` | `--extras EXTRAS` | ... | + cols = [c.strip() for c in line.split("|")] + if len(cols) < 4: + continue + + short_col = cols[1] # e.g. "`-x EXTRAS`" + long_col = cols[2] # e.g. "`--extras EXTRAS`" + + short_m = re.match(r"`-(\w)", short_col) + long_m = re.match(r"`--([\w-]+)", long_col) + + if short_m and long_m: + short = short_m.group(1) + long = long_m.group(1) + if current_macro not in result: + result[current_macro] = set() + result[current_macro].add((short, long)) + + return result + + +@pytest.fixture(scope="module") +def macro_opts(): + return parse_macros_file() + + +@pytest.fixture(scope="module") +def readme_opts(): + return parse_readme() + + +@pytest.mark.parametrize("macro_name", MACROS_PYTHON) +def test_macro_options_match_python(macro_opts, macro_name): + python_opts = parse_python_script(macro_name) + macro_set = macro_opts[macro_name] + assert macro_set + assert macro_set == python_opts + + +@pytest.mark.parametrize("macro_name", MACROS_README) +def test_macro_options_match_readme(macro_opts, readme_opts, macro_name): + macro_set = macro_opts[macro_name] + readme_set = readme_opts.get(macro_name, set()) + assert macro_set + assert macro_set == readme_set diff --git a/test_pyproject_getopt_parser.py b/test_pyproject_getopt_parser.py new file mode 100644 index 0000000..6236223 --- /dev/null +++ b/test_pyproject_getopt_parser.py @@ -0,0 +1,98 @@ +"""Unit tests for pyproject_getopt.lua option parsing. + +Most tests are written in Lua (test_pyproject_getopt.lua) and run via +parametrized test_lua. Tests that need to inspect stderr for error +messages or define real RPM parametric macros stay in Python. +""" + +import os +import subprocess +import textwrap +from pathlib import Path + +import pytest + +BASEDIR = Path(__file__).parent + + +def rpmlua(script): + """Run a Lua script via rpm --eval, return (stdout, stderr, returncode).""" + full_script = textwrap.dedent(f"""\ + package.path = "{BASEDIR}/?.lua" + """) + textwrap.dedent(script) + r = subprocess.run( + # ["rpmlua", "-e", full_script] when we no longer support c9s + ["rpm", "--eval", "%{lua:" + full_script + "}"], + capture_output=True, text=True, + env={**os.environ, "LANG": "C.UTF-8"}, + ) + return r.stdout, r.stderr, r.returncode + + +def _get_lua_test_names(): + stdout, _, rc = rpmlua("""\ + local t = require("test_pyproject_getopt") + for _, name in ipairs(t.list()) do + io.write(name .. "\\n") + end + """) + assert rc == 0, "Failed to list Lua tests" + names = stdout.strip().splitlines() + assert names, "No Lua tests found" + return names + + +@pytest.mark.parametrize("lua_test_name", _get_lua_test_names()) +def test_lua(lua_test_name): + stdout, stderr, rc = rpmlua(f"""\ + local t = require("test_pyproject_getopt") + t.{lua_test_name}() + """) + if rc != 0: + raise AssertionError(stderr) + if stdout.startswith("SKIP: "): + pytest.skip(stdout.removeprefix("SKIP: ").strip()) + + +class TestErrorMessages: + """Verify that error messages contain expected text (via stderr). + + These call raw_* functions from the Lua test module which invoke + getopt() without pcall, so the RPM error propagates to stderr. + """ + + def _run_raw(self, name): + _, stderr, rc = rpmlua(f"require('test_pyproject_getopt').{name}()") + assert rc != 0, "expected an error" + return stderr + + def test_unknown_option_message(self): + stderr = self._run_raw("raw_unknown_short_option") + assert "unknown option: -z" in stderr + + def test_unknown_long_option_message(self): + stderr = self._run_raw("raw_unknown_long_option") + assert "unknown option: --bogus" in stderr + + def test_missing_value_message(self): + stderr = self._run_raw("raw_missing_value_short") + assert "requires a value" in stderr + + def test_flag_given_value_message(self): + stderr = self._run_raw("raw_flag_given_value") + assert "does not take a value" in stderr + + def test_mutual_exclusion_message(self): + stderr = self._run_raw("raw_R_and_x") + assert "mutually exclusive" in stderr + assert "--no-runtime" in stderr + assert "--extras" in stderr + + def test_repeated_without_separator_message(self): + stderr = self._run_raw("raw_repeated_without_separator") + assert "cannot be repeated" in stderr + + def test_macro_rejects_unknown_option_message(self): + stderr = self._run_raw("raw_macro_rejects_unknown_option") + assert "%_test_macro" in stderr + assert "unknown option: --bogus" in stderr diff --git a/tests/config-settings-test.spec b/tests/config-settings-test.spec index 15f2467..dc239cc 100644 --- a/tests/config-settings-test.spec +++ b/tests/config-settings-test.spec @@ -37,12 +37,12 @@ EOF %generate_buildrequires -%pyproject_buildrequires -C abc=123 -C xyz=456 -C--option-with-dashes=1 -C--option-with-dashes=2 -%{!?el9:%pyproject_buildrequires -C abc=123 -C xyz=456 -C--option-with-dashes=1 -C--option-with-dashes=2 -w} +%pyproject_buildrequires --config-settings abc=123 -C xyz=456 --config-settings=--option-with-dashes=1 -C--option-with-dashes=2 +%{!?el9:%pyproject_buildrequires --config-settings abc=123 -C xyz=456 --config-settings=--option-with-dashes=1 -C--option-with-dashes=2 --wheel} %build -%{!?el9:%pyproject_wheel -C abc=123 -C xyz=456 -C--option-with-dashes=1 -C--option-with-dashes=2} +%{!?el9:%pyproject_wheel --config-settings abc=123 --config-settings xyz=456 --config-settings=--option-with-dashes=1 --config-settings=--option-with-dashes=2} %changelog diff --git a/tests/double-install.spec b/tests/double-install.spec index afe8c90..13fe33d 100644 --- a/tests/double-install.spec +++ b/tests/double-install.spec @@ -24,13 +24,13 @@ tar xf %{SOURCE2} %generate_buildrequires -%pyproject_buildrequires -R -d markupsafe-%{markupsafe_version} -%pyproject_buildrequires -R -d tldr-%{tldr_version} +%pyproject_buildrequires --no-runtime --directory markupsafe-%{markupsafe_version} +%pyproject_buildrequires --no-runtime --directory tldr-%{tldr_version} %build -%pyproject_wheel -d markupsafe-%{markupsafe_version} -%pyproject_wheel -d tldr-%{tldr_version} +%pyproject_wheel --directory markupsafe-%{markupsafe_version} +%pyproject_wheel --directory tldr-%{tldr_version} %install diff --git a/tests/fake-requirements.spec b/tests/fake-requirements.spec index c0da8e8..d4a4077 100644 --- a/tests/fake-requirements.spec +++ b/tests/fake-requirements.spec @@ -9,7 +9,7 @@ BuildRequires: pyproject-rpm-macros %description -Fake spec file to test %%pyproject_buildrequires -N works as expected +Fake spec file to test %%pyproject_buildrequires --no-use-build-system works as expected %prep cat > requirements.txt <=0.10.0 EOF %generate_buildrequires -%pyproject_buildrequires requirements.txt -N +%pyproject_buildrequires requirements.txt --no-use-build-system %check diff --git a/tests/python-coverage-pth.spec b/tests/python-coverage-pth.spec index a0d1096..e946907 100644 --- a/tests/python-coverage-pth.spec +++ b/tests/python-coverage-pth.spec @@ -40,10 +40,10 @@ sed -i 's/d)/d+)/' setup.py # internal check for our macros: # this should not work without -M -%pyproject_save_files -L && exit 1 || true +%pyproject_save_files --no-assert-license && exit 1 || true # but this should: -%pyproject_save_files -LM +%pyproject_save_files --no-assert-license --allow-no-modules %files -n python3-coverage-pth -f %{pyproject_files} diff --git a/tests/python-distroinfo.spec b/tests/python-distroinfo.spec index 44fc952..b545ef2 100644 --- a/tests/python-distroinfo.spec +++ b/tests/python-distroinfo.spec @@ -47,7 +47,7 @@ Summary: %{summary} %check %pytest -%pyproject_check_import -t +%pyproject_check_import --top-level-only %files -n python3-distroinfo -f %{pyproject_files} diff --git a/tests/python-ipykernel.spec b/tests/python-ipykernel.spec index 37ab86d..68a0b1b 100644 --- a/tests/python-ipykernel.spec +++ b/tests/python-ipykernel.spec @@ -36,17 +36,17 @@ Summary: %{summary} %pyproject_patch_dependency debugpy:ignore %generate_buildrequires -%pyproject_buildrequires -r +%pyproject_buildrequires --runtime %build %pyproject_wheel %install %pyproject_install -%pyproject_save_files -l 'ipykernel*' +auto +%pyproject_save_files --assert-license 'ipykernel*' +auto %check -%pyproject_check_import -e '*.test*' -e 'ipykernel.gui*' -e 'ipykernel.pylab.*' -e 'ipykernel.trio*' -e 'ipykernel.datapub' -e 'ipykernel.pickleutil' -e 'ipykernel.serialize' +%pyproject_check_import --exclude '*.test*' --exclude 'ipykernel.gui*' --exclude 'ipykernel.pylab.*' --exclude 'ipykernel.trio*' --exclude 'ipykernel.datapub' --exclude 'ipykernel.pickleutil' --exclude 'ipykernel.serialize' %files -n python3-ipykernel -f %{pyproject_files} %doc README.md diff --git a/tests/python-markdown-it-py.spec b/tests/python-markdown-it-py.spec index 5c34b21..06107bd 100644 --- a/tests/python-markdown-it-py.spec +++ b/tests/python-markdown-it-py.spec @@ -26,14 +26,14 @@ Summary: %{summary} %autosetup -p1 -n markdown-it-py-%{version} %generate_buildrequires -%pyproject_buildrequires -x testing,linkify -p +%pyproject_buildrequires --extras testing,linkify --pyproject-dependencies %build %pyproject_wheel %install %pyproject_install -%pyproject_save_files markdown_it -L +%pyproject_save_files markdown_it --no-assert-license %check # sphinx-copybutton is in [rtd] extra, should not appear diff --git a/tests/python-pluggy.spec b/tests/python-pluggy.spec index 002b28d..c87e5b7 100644 --- a/tests/python-pluggy.spec +++ b/tests/python-pluggy.spec @@ -36,7 +36,7 @@ sed -i 's/{env:_PYTEST_DEP:pytest}$/{env:_PYTEST_DEP:pytest<8}/' tox.ini %generate_buildrequires -%pyproject_buildrequires -t +%pyproject_buildrequires --tox %build @@ -46,7 +46,7 @@ sed -i 's/{env:_PYTEST_DEP:pytest}$/{env:_PYTEST_DEP:pytest<8}/' tox.ini %install %pyproject_install # There are no executables, but we are allowed to pass +auto anyway -%pyproject_save_files pluggy +auto -l +%pyproject_save_files pluggy +auto --assert-license %check