Add long option support for all public parametric macros

All public parametric macros now accept long options in addition
to short options. For example, %pyproject_buildrequires --no-runtime
is equivalent to %pyproject_buildrequires -R.

This is implemented via a Lua option parser (pyproject_getopt.lua)
installed to %{_rpmluadir}/fedora/rpm/. All macros use (-) to opt out
of RPM native getopt and call the Lua parser for validation, mutual
exclusion checks, and value extraction.

Other changes:
- Toxenv construction inlined in Lua, replacing pyproject_construct_toxenv.py
- Added --config-settings and --directory long forms to Python argparse
- Renamed --read-pyproject-dependencies to --pyproject-dependencies
  (this was not part of API and now it is, so I picked a shorter name)
- Added test_getopt_consistency.py to assert option mappings match
  across macros.pyproject, Python scripts, and README.md
- Converted ~half of test specs to use long options
- Documented all short/long mappings in README.md
- All Python scripts expose their argparser() functions fer easier consistency asserts

Co-Authored-By: Maxwell G <maxwell@gtmx.me>
Assisted-By: Claude Opus 4.6
(cherry picked from Fedora commit 6d3ad45c08)
This commit is contained in:
Miro Hrončok 2026-04-21 13:05:31 +02:00
parent e401246169
commit ef9de25d3f
20 changed files with 1199 additions and 141 deletions

123
README.md
View File

@ -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.

View File

@ -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 <requirements-file> 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 <requirements-file> 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}
}

View File

@ -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 <mhroncok@redhat.com> - 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 <thrnciar@redhat.com> - 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.

View File

@ -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,

View File

@ -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

View File

@ -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:]))

256
pyproject_getopt.lua Normal file
View File

@ -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

View File

@ -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

View File

@ -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', []),

460
test_pyproject_getopt.lua Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 <<EOF
@ -18,7 +18,7 @@ tomli>=0.10.0
EOF
%generate_buildrequires
%pyproject_buildrequires requirements.txt -N
%pyproject_buildrequires requirements.txt --no-use-build-system
%check

View File

@ -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}

View File

@ -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}

View File

@ -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

View File

@ -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

View File

@ -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