pyproject-rpm-macros/pyproject_getopt.lua
Miro Hrončok ef9de25d3f 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)
2026-08-19 17:32:22 +02:00

257 lines
7.8 KiB
Lua

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