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:
parent
e401246169
commit
ef9de25d3f
123
README.md
123
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.
|
||||
|
||||
|
||||
|
||||
100
macros.pyproject
100
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 <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}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
256
pyproject_getopt.lua
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
460
test_pyproject_getopt.lua
Normal 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
|
||||
167
test_pyproject_getopt_consistency.py
Normal file
167
test_pyproject_getopt_consistency.py
Normal 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
|
||||
98
test_pyproject_getopt_parser.py
Normal file
98
test_pyproject_getopt_parser.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user