proj/portage:master commit in: lib/portage/tests/dep/
"Matt Turner" <[email protected]>
| Newsgroups | gmane.linux.gentoo.cvs |
|---|---|
| Message-ID | <1785963928.06da2f580686e115ce2b278da31f62512f8dc37f.mattst88@gentoo> |
commit: 06da2f580686e115ce2b278da31f62512f8dc37f
Author: Matt Turner <mattst88 <AT> gentoo <DOT> org>
AuthorDate: Sun Aug 2 20:18:11 2026 +0000
Commit: Matt Turner <mattst88 <AT> gentoo <DOT> org>
CommitDate: Wed Aug 5 21:05:28 2026 +0000
URL: https://gitweb.gentoo.org/proj/portage.git/commit/?id=06da2f58
tests: add C parser parity and classify_use_deps tests
_UseReduceTests is a mixin that exercises use_reduce() with
token_class=Atom; TestUseReducePythonPath and TestUseReduceCPath run it
with the C parser disabled and enabled respectively, so every
correctness assertion runs on both code paths.
- TestCParserRawAtom checks the raw _parser.Atom field values.
- TestCAtomObjectProtocol covers _parser.Atom's str/repr/hash/equality,
including comparison against a foreign type, and that ordering is not
implemented.
- TestClassifyUseDeps unit-tests _parser.classify_use_deps() for each
token form (enabled, disabled, conditional, missing-default).
- TestCFastVsPython asserts byte-identical output between paths on a
representative set of dep strings and confirms both raise
InvalidDependString on the same malformed inputs.
- TestCParserMalformedGroups covers the group syntax errors that each
have their own path in scan_item()/scan_group_contents(): missing
whitespace after '(' or after a "||"/"flag?" prefix, and unterminated
groups.
- TestCParserInactiveConditionalBodies covers the skip visitor, which
parses the body of an inactive use conditional without emitting it, so
nested groups and nested conditionals are still validated and syntax
errors inside a body that is never emitted are still reported.
- TestCParserWhitespace covers leading, trailing and repeated
whitespace, which the C parser handles itself because it sees the dep
string unsplit.
- TestCParserEapiGuard covers eapi in {None,0,1,4,5,6,7,8}: older EAPIs
reject modern syntax through both paths, and valid strings produce
identical results.
- TestKillSwitch runs a subprocess per case, since
PORTAGE_NATIVE_DEP_PARSER is consulted when portage.dep is imported.
- TestLongAtoms covers category and package names either side of the
point where the C path stops using a stack buffer to join cp and cpv.
- TestDeepNesting checks that the C parser's heap-grown group stack
imposes no nesting limit the pure-Python path lacks, across the
boundary where that stack spills to the heap.
Every _c_dep_parser toggle goes through the _use_c_parser() context
manager, so the invariant -- a toggle never outlives a single call, and
a failing assertion cannot leak it into the next test -- is stated in
one place. pytest-xdist distributes over processes rather than threads,
so each worker has its own copy of portage.dep and cannot race.
Signed-off-by: Matt Turner <mattst88 <AT> gentoo.org>
lib/portage/tests/dep/meson.build | 1 +
lib/portage/tests/dep/test_c_parser.py | 1072 ++++++++++++++++++++++++++++++++
2 files changed, 1073 insertions(+)
diff --git a/lib/portage/tests/dep/meson.build b/lib/portage/tests/dep/meson.build
index 7350f7775..e438c88d0 100644
--- a/lib/portage/tests/dep/meson.build
+++ b/lib/portage/tests/dep/meson.build
@@ -2,6 +2,7 @@ py.install_sources(
[
'test_atom.py',
'test_check_required_use.py',
+ 'test_c_parser.py',
'test_extended_atom_dict.py',
'test_extract_affecting_use.py',
'test_standalone.py',
diff --git a/lib/portage/tests/dep/test_c_parser.py b/lib/portage/tests/dep/test_c_parser.py
new file mode 100644
index 000000000..d845f4367
--- /dev/null
+++ b/lib/portage/tests/dep/test_c_parser.py
@@ -0,0 +1,1072 @@
+# Copyright 2026 Gentoo Authors
+# Distributed under the terms of the GNU General Public License v2
+
+import contextlib
+import operator
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import portage
+import portage.dep as _dep_mod
+from portage.dep import Atom, _get_eapi_attrs, _use_dep, use_reduce
+from portage.exception import InvalidDependString
+from portage.tests import TestCase
+
+_orig_c_dep_parser = _dep_mod._c_dep_parser
+
+
[email protected]
+def _use_c_parser(enabled):
+ """Select the parser used by portage.dep for the duration of the block.
+
+ portage.dep._c_dep_parser is module state, so a test may not leave it
+ toggled and two tests may not toggle it concurrently in one interpreter.
+ Every toggle in this file goes through here, and always around a single
+ call, so a failing assertion cannot leak the setting into the next test.
+ pytest-xdist distributes over processes rather than threads, so each
+ worker has its own copy of the module and cannot race with another."""
+ _dep_mod._c_dep_parser = _orig_c_dep_parser if enabled else None
+ try:
+ yield
+ finally:
+ _dep_mod._c_dep_parser = _orig_c_dep_parser
+
+
+def _c_parser():
+ try:
+ from portage.dep import _parser
+
+ return _parser
+ except ImportError:
+ return None
+
+
+def _atoms_equal(a, b):
+ for f in (
+ "_string",
+ "_cp",
+ "_cpv",
+ "_version",
+ "_operator",
+ "_slot",
+ "_sub_slot",
+ "_slot_operator",
+ "_eapi",
+ "_extended_syntax",
+ "_build_id",
+ ):
+ av, bv = getattr(a, f), getattr(b, f)
+ if av != bv:
+ return False, f"{f}: {av!r} != {bv!r}"
+
+ ab, bb = a._blocker_obj, b._blocker_obj
+ if bool(ab) != bool(bb):
+ return False, f"_blocker_obj presence: {ab!r} != {bb!r}"
+ if ab and bb and ab.overlap.forbid != bb.overlap.forbid:
+ return False, "_blocker_obj.overlap.forbid mismatch"
+
+ au, bu = a._use, b._use
+ if (au is None) != (bu is None):
+ return False, f"_use presence: {au!r} != {bu!r}"
+ if au is not None and bu is not None:
+ for attr in (
+ "tokens",
+ "enabled",
+ "disabled",
+ "required",
+ "missing_enabled",
+ "missing_disabled",
+ ):
+ av2, bv2 = getattr(au, attr), getattr(bu, attr)
+ if av2 != bv2:
+ return False, f"_use.{attr}: {av2!r} != {bv2!r}"
+ ac, bc = au.conditional, bu.conditional
+ if (ac is None) != (bc is None):
+ return False, f"_use.conditional presence: {ac!r} != {bc!r}"
+ if ac is not None and bc is not None:
+ for k in ("enabled", "disabled", "equal", "not_equal"):
+ av2, bv2 = getattr(ac, k), getattr(bc, k)
+ if av2 != bv2:
+ return False, f"_use.conditional.{k}: {av2!r} != {bv2!r}"
+ return True, ""
+
+
+def _result_equal(c_result, py_result):
+ if len(c_result) != len(py_result):
+ return False, f"length {len(c_result)} != {len(py_result)}"
+ for i, (ci, pi) in enumerate(zip(c_result, py_result)):
+ if isinstance(ci, list) and isinstance(pi, list):
+ ok, msg = _result_equal(ci, pi)
+ if not ok:
+ return False, f"[{i}]: {msg}"
+ elif isinstance(ci, str) and isinstance(pi, str):
+ if ci != pi:
+ return False, f"[{i}]: {ci!r} != {pi!r}"
+ elif isinstance(ci, Atom) and isinstance(pi, Atom):
+ ok, msg = _atoms_equal(ci, pi)
+ if not ok:
+ return False, f"[{i}] Atom mismatch: {msg}"
+ else:
+ return False, f"[{i}]: type mismatch {type(ci)} vs {type(pi)}"
+ return True, ""
+
+
+class _UseReduceTests:
+ """Mixin run with USE_C_PARSER True or False; subclasses set it."""
+
+ USE_C_PARSER = False
+
+ def setUp(self):
+ _dep_mod._c_dep_parser = _orig_c_dep_parser if self.USE_C_PARSER else None
+
+ def tearDown(self):
+ _dep_mod._c_dep_parser = _orig_c_dep_parser
+
+ def _reduce(self, depstr, uselist=None, matchall=False, eapi="8", **kw):
+ return use_reduce(
+ depstr,
+ uselist=uselist or [],
+ matchall=matchall,
+ token_class=Atom,
+ eapi=eapi,
+ **kw,
+ )
+
+ def _atom(self, depstr, **kw):
+ result = self._reduce(depstr, **kw)
+ self.assertEqual(len(result), 1)
+ self.assertIsInstance(result[0], Atom)
+ return result[0]
+
+ def test_unversioned_atom_fields(self):
+ a = self._atom("dev-libs/foo", matchall=True)
+ self.assertEqual(a._cp, "dev-libs/foo")
+ self.assertEqual(a._cpv, "dev-libs/foo")
+ self.assertIsNone(a._version)
+ self.assertIsNone(a._operator)
+ self.assertIsNone(a._slot)
+ self.assertIsNone(a._sub_slot)
+ self.assertIsNone(a._slot_operator)
+ self.assertIsNone(a._use)
+ self.assertIsNone(a._blocker_obj)
+
+ def test_versioned_atom_fields(self):
+ a = self._atom("=dev-libs/foo-1.2.3-r1", matchall=True)
+ self.assertEqual(a._cp, "dev-libs/foo")
+ self.assertEqual(a._version, "1.2.3-r1")
+ self.assertEqual(a._operator, "=")
+ self.assertEqual(a._cpv, "dev-libs/foo-1.2.3-r1")
+
+ def test_all_operators(self):
+ for op in ("=", ">=", ">", "<=", "<", "~"):
+ with self.subTest(op=op):
+ a = self._atom(f"{op}cat/pkg-1.0", matchall=True)
+ self.assertEqual(a._operator, op)
+ self.assertEqual(a._version, "1.0")
+
+ def test_version_suffixes(self):
+ for ver in ("1.0_alpha1", "1.0_beta2", "1.0_pre1", "1.0_rc1", "1.0_p1"):
+ with self.subTest(ver=ver):
+ a = self._atom(f"=cat/pkg-{ver}", matchall=True)
+ self.assertEqual(a._version, ver)
+
+ def test_slot_fields(self):
+ a = self._atom("dev-libs/foo:1", matchall=True)
+ self.assertEqual(a._slot, "1")
+ self.assertIsNone(a._sub_slot)
+ self.assertIsNone(a._slot_operator)
+
+ def test_sub_slot_fields(self):
+ a = self._atom("dev-libs/foo:0/53", matchall=True)
+ self.assertEqual(a._slot, "0")
+ self.assertEqual(a._sub_slot, "53")
+ self.assertIsNone(a._slot_operator)
+
+ def test_slot_operator_eq(self):
+ a = self._atom("dev-libs/foo:0=", matchall=True)
+ self.assertEqual(a._slot, "0")
+ self.assertEqual(a._slot_operator, "=")
+
+ def test_slot_operator_star(self):
+ a = self._atom("dev-libs/foo:*", matchall=True)
+ self.assertIsNone(a._slot)
+ self.assertEqual(a._slot_operator, "*")
+
+ def test_slot_operator_bare_eq(self):
+ a = self._atom("dev-libs/foo:=", matchall=True)
+ self.assertIsNone(a._slot)
+ self.assertEqual(a._slot_operator, "=")
+
+ def test_blocker_weak(self):
+ a = self._atom("!dev-libs/foo", matchall=True)
+ self.assertIsNotNone(a._blocker_obj)
+ self.assertFalse(a._blocker_obj.overlap.forbid)
+
+ def test_blocker_strong(self):
+ a = self._atom("!!dev-libs/foo", matchall=True)
+ self.assertIsNotNone(a._blocker_obj)
+ self.assertTrue(a._blocker_obj.overlap.forbid)
+
+ def test_combined_fields(self):
+ a = self._atom(
+ "=sys-apps/portage-2.1-r1:0[doc,a=,!b=,c?,!d?,-e]",
+ matchall=True,
+ )
+ self.assertEqual(a._cp, "sys-apps/portage")
+ self.assertEqual(a._version, "2.1-r1")
+ self.assertEqual(a._operator, "=")
+ self.assertEqual(a._slot, "0")
+ self.assertIsNotNone(a._use)
+ self.assertEqual(a._use.tokens, ("doc", "a=", "!b=", "c?", "!d?", "-e"))
+
+ def test_use_enabled(self):
+ a = self._atom("dev-libs/foo[bar]", matchall=True)
+ self.assertIsNotNone(a._use)
+ self.assertIn("bar", a._use.enabled)
+ self.assertEqual(a._use.disabled, frozenset())
+
+ def test_use_disabled(self):
+ a = self._atom("dev-libs/foo[-bar]", matchall=True)
+ self.assertIn("bar", a._use.disabled)
+ self.assertEqual(a._use.enabled, frozenset())
+
+ def test_use_conditional_enabled(self):
+ a = self._atom("dev-libs/foo[bar?]", matchall=True)
+ self.assertIsNotNone(a._use.conditional)
+ self.assertIn("bar", a._use.conditional.enabled)
+
+ def test_use_conditional_disabled(self):
+ a = self._atom("dev-libs/foo[!bar?]", matchall=True)
+ self.assertIsNotNone(a._use.conditional)
+ self.assertIn("bar", a._use.conditional.disabled)
+
+ def test_use_equal(self):
+ a = self._atom("dev-libs/foo[bar=]", matchall=True)
+ self.assertIn("bar", a._use.conditional.equal)
+
+ def test_use_not_equal(self):
+ a = self._atom("dev-libs/foo[!bar=]", matchall=True)
+ self.assertIn("bar", a._use.conditional.not_equal)
+
+ def test_use_missing_enabled_default(self):
+ a = self._atom("dev-libs/foo[bar(+)]", matchall=True)
+ self.assertIn("bar", a._use.missing_enabled)
+
+ def test_use_missing_disabled_default(self):
+ a = self._atom("dev-libs/foo[bar(-)]", matchall=True)
+ self.assertIn("bar", a._use.missing_disabled)
+
+ def test_use_str(self):
+ a = self._atom("dev-libs/foo[bar,-baz]", matchall=True)
+ self.assertEqual(str(a._use), "[bar,-baz]")
+
+ def test_conditional_active(self):
+ a = self._atom("dev-libs/foo[bar?]", uselist=["bar"])
+ self.assertIsNone(a._use.conditional)
+ self.assertIn("bar", a._use.enabled)
+
+ def test_conditional_inactive(self):
+ a = self._atom("dev-libs/foo[bar?]", uselist=[])
+ self.assertIsNone(a._use)
+
+ def test_conditional_not_equal_active(self):
+ a = self._atom("dev-libs/foo[!bar=]", uselist=["bar"])
+ self.assertIsNone(a._use.conditional)
+ self.assertIn("bar", a._use.disabled)
+
+ def test_conditional_not_equal_inactive(self):
+ a = self._atom("dev-libs/foo[!bar=]", uselist=[])
+ self.assertIsNone(a._use.conditional)
+ self.assertIn("bar", a._use.enabled)
+
+ def test_or_group(self):
+ result = self._reduce("|| ( dev-libs/a dev-libs/b )", matchall=True)
+ self.assertEqual(result[0], "||")
+ self.assertIsInstance(result[1], list)
+ self.assertEqual(len(result[1]), 2)
+
+ def test_use_conditional_group_active(self):
+ result = self._reduce("foo? ( dev-libs/a dev-libs/b )", uselist=["foo"])
+ atoms = [x for x in result if isinstance(x, Atom)]
+ self.assertEqual(len(atoms), 2)
+
+ def test_use_conditional_group_inactive(self):
+ result = self._reduce("foo? ( dev-libs/a dev-libs/b )", uselist=[])
+ self.assertEqual(result, [])
+
+ def test_nested_groups(self):
+ result = self._reduce(
+ "a? ( || ( dev-libs/a1 dev-libs/a2 ) b? ( dev-libs/b ) )",
+ uselist=["a", "b"],
+ )
+ self.assertIn("||", result)
+
+ def test_matchall_expands_all(self):
+ result = self._reduce("a? ( dev-libs/A ) !b? ( dev-libs/B )", matchall=True)
+ cps = [a._cp for a in result if isinstance(a, Atom)]
+ self.assertIn("dev-libs/A", cps)
+ self.assertIn("dev-libs/B", cps)
+
+ def test_glob_version(self):
+ a = self._atom("=dev-libs/foo-1.2*", matchall=True)
+ self.assertEqual(a._cp, "dev-libs/foo")
+ self.assertEqual(a._operator, "=*")
+ self.assertIn("1.2", a._version)
+
+ def test_complex_depstr(self):
+ result = self._reduce(
+ "dev-libs/A >=dev-libs/B-2.0 || ( dev-libs/C dev-libs/D ) "
+ "foo? ( =dev-libs/E-1.0:0[bar,baz?] )",
+ uselist=["foo"],
+ )
+ cps = [a._cp for a in result if isinstance(a, Atom)]
+ self.assertIn("dev-libs/A", cps)
+ self.assertIn("dev-libs/B", cps)
+ self.assertIn("dev-libs/E", cps)
+ self.assertEqual(result[2], "||")
+ self.assertIsInstance(result[3], list)
+ or_cps = [a._cp for a in result[3] if isinstance(a, Atom)]
+ self.assertIn("dev-libs/C", or_cps)
+ self.assertIn("dev-libs/D", or_cps)
+
+ def test_opconvert(self):
+ result = use_reduce(
+ "|| ( dev-libs/a dev-libs/b )",
+ token_class=Atom,
+ matchall=True,
+ eapi="8",
+ opconvert=True,
+ )
+ self.assertIsInstance(result, list)
+
+ def test_flat(self):
+ result = use_reduce(
+ "a? ( dev-libs/a ) dev-libs/b",
+ token_class=Atom,
+ matchall=True,
+ eapi="8",
+ flat=True,
+ )
+ self.assertIsInstance(result, list)
+
+ def test_no_token_class(self):
+ result = use_reduce("dev-libs/a dev-libs/b", matchall=True, eapi="8")
+ self.assertTrue(all(isinstance(x, str) for x in result))
+
+ def test_invalid_missing_close_paren(self):
+ with self.assertRaises(InvalidDependString):
+ self._reduce("|| ( dev-libs/a dev-libs/b", matchall=True)
+
+ def test_invalid_extra_close_paren(self):
+ with self.assertRaises(InvalidDependString):
+ self._reduce("dev-libs/a )", matchall=True)
+
+ def test_invalid_atom_no_category(self):
+ with self.assertRaises(InvalidDependString):
+ self._reduce("foo", matchall=True)
+
+ def test_invalid_operator_no_version(self):
+ with self.assertRaises(InvalidDependString):
+ self._reduce(">=dev-libs/foo", matchall=True)
+
+ def test_eapi5(self):
+ a = self._atom("dev-libs/foo[bar=]", matchall=True, eapi="5")
+ self.assertIn("bar", a._use.conditional.equal)
+
+ def test_eapi6(self):
+ a = self._atom("dev-libs/foo:0/1=[bar,!baz?]", matchall=True, eapi="6")
+ self.assertEqual(a._slot, "0")
+ self.assertEqual(a._sub_slot, "1")
+
+
+class TestUseReducePythonPath(_UseReduceTests, TestCase):
+ USE_C_PARSER = False
+
+
+class TestUseReduceCPath(_UseReduceTests, TestCase):
+ USE_C_PARSER = True
+
+ def setUp(self):
+ if _orig_c_dep_parser is None:
+ self.skipTest("_parser extension not available")
+ super().setUp()
+
+
+class TestCParserRawAtom(TestCase):
+ def setUp(self):
+ self._parser = _c_parser()
+ if self._parser is None:
+ self.skipTest("_parser extension not available")
+
+ def test_parse_returns_list(self):
+ result = self._parser.parse("cat/pkg", matchall=True)
+ self.assertIsInstance(result, list)
+ self.assertEqual(len(result), 1)
+ self.assertIsInstance(result[0], self._parser.Atom)
+
+ def test_raw_atom_fields(self):
+ a = self._parser.parse(
+ "=sys-apps/portage-2.1-r1:0/1=[foo,-bar]", matchall=True
+ )[0]
+ self.assertEqual(a.cp, "sys-apps/portage")
+ self.assertEqual(a.version, "2.1-r1")
+ self.assertEqual(a.operator, "=")
+ self.assertEqual(a.slot, "0")
+ self.assertEqual(a.sub_slot, "1")
+ self.assertEqual(a.slot_operator, "=")
+ self.assertEqual(tuple(a.use), ("foo", "-bar"))
+ self.assertIsNone(a.blocker)
+
+ def test_raw_atom_blocker(self):
+ a = self._parser.parse("!!dev-libs/foo", matchall=True)[0]
+ self.assertEqual(a.blocker, "!!")
+ a2 = self._parser.parse("!dev-libs/foo", matchall=True)[0]
+ self.assertEqual(a2.blocker, "!")
+
+ def test_raw_atom_glob_version(self):
+ a = self._parser.parse("=dev-libs/foo-1.2*", matchall=True)[0]
+ self.assertEqual(a.cp, "dev-libs/foo")
+ self.assertEqual(a.operator, "=") # _c_atom_from_c converts this to "=*"
+ self.assertEqual(a.version, "1.2*")
+
+
+class TestClassifyUseDeps(TestCase):
+ def setUp(self):
+ self._parser = _c_parser()
+ if self._parser is None:
+ self.skipTest("_parser extension not available")
+
+ def _classify(self, tokens):
+ return self._parser.classify_use_deps(tokens)
+
+ def test_enabled(self):
+ en, dis, _, _, cond, req = self._classify(("foo",))
+ self.assertEqual(en, frozenset({"foo"}))
+ self.assertEqual(dis, frozenset())
+ self.assertIsNone(cond)
+ self.assertEqual(req, frozenset({"foo"}))
+
+ def test_disabled(self):
+ en, dis, _, _, cond, req = self._classify(("-foo",))
+ self.assertEqual(en, frozenset())
+ self.assertEqual(dis, frozenset({"foo"}))
+ self.assertIsNone(cond)
+ self.assertEqual(req, frozenset({"foo"}))
+
+ def test_conditional_enabled(self):
+ _, _, _, _, cond, _ = self._classify(("foo?",))
+ self.assertIsNotNone(cond)
+ self.assertEqual(cond["enabled"], frozenset({"foo"}))
+
+ def test_conditional_disabled(self):
+ _, _, _, _, cond, _ = self._classify(("!foo?",))
+ self.assertIsNotNone(cond)
+ self.assertEqual(cond["disabled"], frozenset({"foo"}))
+
+ def test_conditional_equal(self):
+ _, _, _, _, cond, _ = self._classify(("foo=",))
+ self.assertIsNotNone(cond)
+ self.assertEqual(cond["equal"], frozenset({"foo"}))
+
+ def test_conditional_not_equal(self):
+ _, _, _, _, cond, _ = self._classify(("!foo=",))
+ self.assertIsNotNone(cond)
+ self.assertEqual(cond["not_equal"], frozenset({"foo"}))
+
+ def test_missing_enabled_default(self):
+ en, _, miss_en, miss_dis, _, req = self._classify(("foo(+)",))
+ self.assertEqual(en, frozenset({"foo"}))
+ self.assertEqual(miss_en, frozenset({"foo"}))
+ self.assertEqual(miss_dis, frozenset())
+ self.assertEqual(req, frozenset()) # has default, not required
+
+ def test_missing_disabled_default(self):
+ _, _, miss_en, miss_dis, _, req = self._classify(("foo(-)",))
+ self.assertEqual(miss_en, frozenset())
+ self.assertEqual(miss_dis, frozenset({"foo"}))
+ self.assertEqual(req, frozenset())
+
+ def test_disabled_with_default(self):
+ _, dis, miss_en, _, _, req = self._classify(("-foo(+)",))
+ self.assertEqual(dis, frozenset({"foo"}))
+ self.assertEqual(miss_en, frozenset({"foo"}))
+ self.assertEqual(req, frozenset())
+
+ def test_mixed(self):
+ en, dis, _, _, cond, req = self._classify(("foo", "-bar", "!baz?", "qux="))
+ self.assertEqual(en, frozenset({"foo"}))
+ self.assertEqual(dis, frozenset({"bar"}))
+ self.assertIsNotNone(cond)
+ self.assertEqual(cond["disabled"], frozenset({"baz"}))
+ self.assertEqual(cond["equal"], frozenset({"qux"}))
+ self.assertEqual(req, frozenset({"foo", "bar", "baz", "qux"}))
+
+ def test_flag_with_hyphen(self):
+ en, dis, _, _, cond, req = self._classify(("foo-bar",))
+ self.assertEqual(en, frozenset({"foo-bar"}))
+ self.assertEqual(req, frozenset({"foo-bar"}))
+
+ def test_disabled_flag_with_hyphen(self):
+ _, dis, _, _, _, req = self._classify(("-foo-bar",))
+ self.assertEqual(dis, frozenset({"foo-bar"}))
+ self.assertEqual(req, frozenset({"foo-bar"}))
+
+ def test_conditional_flag_with_hyphen(self):
+ _, _, _, _, cond, _ = self._classify(("foo-bar?",))
+ self.assertEqual(cond["enabled"], frozenset({"foo-bar"}))
+
+ def test_flag_with_plus(self):
+ en, _, _, _, _, _ = self._classify(("c++",))
+ self.assertEqual(en, frozenset({"c++"}))
+
+ def test_conditional_flag_with_plus(self):
+ _, _, _, _, cond, _ = self._classify(("c++?",))
+ self.assertEqual(cond["enabled"], frozenset({"c++"}))
+
+ def test_flag_with_at(self):
+ en, _, _, _, _, _ = self._classify(("LINGUAS_en@euro",))
+ self.assertEqual(en, frozenset({"LINGUAS_en@euro"}))
+
+ def test_disabled_flag_with_at(self):
+ _, dis, _, _, _, _ = self._classify(("-LINGUAS_en@euro",))
+ self.assertEqual(dis, frozenset({"LINGUAS_en@euro"}))
+
+ def test_invalid_token_raises(self):
+ for bad in ("!foo", "!!foo", "?", "=", "foo??", ""):
+ with self.subTest(token=bad):
+ with self.assertRaises(ValueError):
+ self._parser.classify_use_deps((bad,))
+
+ def test_matches_python_use_dep(self):
+ eapi_attrs = _get_eapi_attrs("8")
+ cases = [
+ ("foo",),
+ ("-foo",),
+ ("foo?",),
+ ("!foo?",),
+ ("foo=",),
+ ("!foo=",),
+ ("foo(+)",),
+ ("foo(-)",),
+ ("-foo(+)",),
+ ("foo", "-bar", "!baz?", "qux=", "quux(+)"),
+ ("a", "b?", "!c?", "d=", "!e=", "-f", "g(+)", "h(-)"),
+ ("foo-bar",),
+ ("-foo-bar",),
+ ("foo-bar?",),
+ ("c++",),
+ ("-c++",),
+ ("c++?",),
+ ("LINGUAS_en@euro",),
+ ("-LINGUAS_en@euro",),
+ ]
+ for tokens in cases:
+ with self.subTest(tokens=tokens):
+ en, dis, miss_en, miss_dis, cond, req = self._parser.classify_use_deps(
+ tokens
+ )
+ c_use = _use_dep(
+ tokens,
+ eapi_attrs,
+ enabled_flags=en,
+ disabled_flags=dis,
+ missing_enabled=miss_en,
+ missing_disabled=miss_dis,
+ conditional=cond,
+ required=req,
+ )
+ py_use = _use_dep(list(tokens), eapi_attrs)
+ for attr in (
+ "enabled",
+ "disabled",
+ "required",
+ "missing_enabled",
+ "missing_disabled",
+ ):
+ self.assertEqual(
+ getattr(c_use, attr),
+ getattr(py_use, attr),
+ f"{attr} mismatch for {tokens}",
+ )
+ if py_use.conditional is None:
+ self.assertIsNone(c_use.conditional)
+ else:
+ self.assertIsNotNone(c_use.conditional)
+ for k in ("enabled", "disabled", "equal", "not_equal"):
+ self.assertEqual(
+ getattr(c_use.conditional, k, frozenset()),
+ getattr(py_use.conditional, k, frozenset()),
+ f"conditional.{k} for {tokens}",
+ )
+
+
+class TestCFastVsPython(TestCase):
+ def setUp(self):
+ if _orig_c_dep_parser is None:
+ self.skipTest("_parser extension not available")
+
+ def _compare(self, depstr, uselist=None, matchall=False, eapi="8"):
+ kw = dict(token_class=Atom, matchall=matchall, eapi=eapi, uselist=uselist or [])
+ with _use_c_parser(False):
+ py_result = use_reduce(depstr, **kw)
+ c_result = use_reduce(depstr, **kw)
+ ok, msg = _result_equal(c_result, py_result)
+ self.assertTrue(ok, f"{depstr!r}: {msg}")
+
+ def test_simple_atom(self):
+ self._compare("dev-libs/foo")
+
+ def test_versioned_atom(self):
+ self._compare("=dev-libs/foo-1.2.3-r1")
+
+ def test_blocker_weak(self):
+ self._compare("!dev-libs/foo")
+
+ def test_blocker_strong(self):
+ self._compare("!!dev-libs/foo")
+
+ def test_slot(self):
+ self._compare("dev-libs/foo:1")
+
+ def test_sub_slot(self):
+ self._compare("dev-libs/foo:1/2")
+
+ def test_slot_operator(self):
+ self._compare("dev-libs/foo:=")
+
+ def test_slot_and_operator(self):
+ self._compare("dev-libs/foo:1=")
+
+ def test_use_enabled(self):
+ self._compare("dev-libs/foo[bar]", matchall=True)
+
+ def test_use_disabled(self):
+ self._compare("dev-libs/foo[-bar]", matchall=True)
+
+ def test_use_multiple(self):
+ self._compare("dev-libs/foo[a,b,c,-d]", matchall=True)
+
+ def test_use_conditional_enabled(self):
+ self._compare("dev-libs/foo[bar?]", matchall=True)
+
+ def test_use_conditional_disabled(self):
+ self._compare("dev-libs/foo[!bar?]", matchall=True)
+
+ def test_use_equal(self):
+ self._compare("dev-libs/foo[bar=]", matchall=True)
+
+ def test_use_not_equal(self):
+ self._compare("dev-libs/foo[!bar=]", matchall=True)
+
+ def test_use_miss_en_default(self):
+ self._compare("dev-libs/foo[bar(+)]", matchall=True)
+
+ def test_use_miss_dis_default(self):
+ self._compare("dev-libs/foo[bar(-)]", matchall=True)
+
+ def test_use_complex(self):
+ self._compare("=sys-apps/portage-2.1-r1:0[doc,a=,!b=,c?,!d?,-e]", matchall=True)
+
+ def test_cond_eval_active(self):
+ self._compare("dev-libs/foo[bar?]", uselist=["bar"])
+
+ def test_cond_eval_inactive(self):
+ self._compare("dev-libs/foo[bar?]", uselist=[])
+
+ def test_cond_not_eq_active(self):
+ self._compare("dev-libs/foo[!bar=]", uselist=["bar"])
+
+ def test_or_group(self):
+ self._compare("|| ( dev-libs/a dev-libs/b )", matchall=True)
+
+ def test_use_cond_group(self):
+ self._compare("foo? ( dev-libs/a dev-libs/b )", uselist=["foo"])
+
+ def test_nested_groups(self):
+ self._compare(
+ "a? ( || ( dev-libs/a1 dev-libs/a2 ) b? ( dev-libs/b ) )",
+ uselist=["a", "b"],
+ )
+
+ def test_complex(self):
+ self._compare(
+ "dev-libs/A >=dev-libs/B-2.0 || ( dev-libs/C dev-libs/D ) "
+ "foo? ( =dev-libs/E-1.0:0[bar,baz?] )",
+ uselist=["foo"],
+ )
+
+ def test_matchall(self):
+ self._compare("a? ( dev-libs/A ) !b? ( dev-libs/B )", matchall=True)
+
+ def test_eapi5(self):
+ self._compare("dev-libs/foo[bar=]", matchall=True, eapi="5")
+
+ def test_eapi6(self):
+ self._compare("dev-libs/foo:0/1=[bar,!baz?]", matchall=True, eapi="6")
+
+ # '@' in USE flag names, deprecated but allowed per PMS (old LINGUAS flags)
+ def test_use_at_sign(self):
+ self._compare("dev-libs/foo[LINGUAS_en@euro]", matchall=True)
+
+ def test_use_at_sign_disabled(self):
+ self._compare("dev-libs/foo[-LINGUAS_en@euro]", matchall=True)
+
+ def test_glob_version(self):
+ self._compare("=dev-libs/foo-1.2*", matchall=True)
+
+ def test_slot_operator_bare_eq(self):
+ self._compare("dev-libs/foo:=", matchall=True)
+
+ def test_invalid_raises_same_exception(self):
+ bad_cases = [
+ "|| ( dev-libs/a dev-libs/b",
+ "dev-libs/a )",
+ ">=dev-libs/foo",
+ ]
+ kw = dict(token_class=Atom, matchall=True, eapi="8", uselist=[])
+ for depstr in bad_cases:
+ with self.subTest(depstr=depstr):
+ with _use_c_parser(False):
+ with self.assertRaises(InvalidDependString):
+ use_reduce(depstr, **kw)
+ with self.assertRaises(InvalidDependString):
+ use_reduce(depstr, **kw)
+
+
+class TestDeepNesting(TestCase):
+ """The C parser keeps its group stack on the heap, so it must not impose a
+ nesting limit the pure-Python path does not have."""
+
+ def setUp(self):
+ if _orig_c_dep_parser is None:
+ self.skipTest("_parser extension not available")
+
+ def _reduce(self, depstr, use_c):
+ with _use_c_parser(use_c):
+ return use_reduce(
+ depstr, token_class=Atom, matchall=True, eapi="8", uselist=[]
+ )
+
+ def test_nesting_parity(self):
+ for depth in (1, 8, 31, 32, 33, 64, 65, 200):
+ with self.subTest(depth=depth):
+ depstr = "( " * depth + "dev-libs/a" + " )" * depth
+ self.assertEqual(
+ self._reduce(depstr, use_c=True),
+ self._reduce(depstr, use_c=False),
+ )
+
+
+class TestLongAtoms(TestCase):
+ """Category and package names are not length-limited, so the C path must
+ not reject an atom that the pure-Python path accepts."""
+
+ def setUp(self):
+ if _orig_c_dep_parser is None:
+ self.skipTest("_parser extension not available")
+
+ def _reduce(self, depstr, use_c):
+ with _use_c_parser(use_c):
+ return use_reduce(
+ depstr, token_class=Atom, matchall=True, eapi="8", uselist=[]
+ )
+
+ def test_long_package_name(self):
+ for length in (8, 250, 255, 256, 300, 512, 1000):
+ with self.subTest(length=length):
+ depstr = "cat/" + "a" * length
+ self.assertEqual(
+ self._reduce(depstr, use_c=True),
+ self._reduce(depstr, use_c=False),
+ )
+
+ def test_long_versioned_atom(self):
+ for length in (250, 256, 600):
+ with self.subTest(length=length):
+ depstr = "=cat/" + "a" * length + "-1.0"
+ c = self._reduce(depstr, use_c=True)
+ py = self._reduce(depstr, use_c=False)
+ self.assertEqual(c, py)
+ self.assertEqual(c[0]._cpv, py[0]._cpv)
+
+ def test_long_category(self):
+ depstr = "c" * 400 + "/pkg"
+ self.assertEqual(
+ self._reduce(depstr, use_c=True), self._reduce(depstr, use_c=False)
+ )
+
+
+class TestCParserMalformedGroups(TestCase):
+ """Whitespace and delimiter errors in group syntax. Every one of these has
+ a distinct error path in scan_item()/scan_group_contents(), and each must
+ be rejected by both parsers."""
+
+ CASES = (
+ "( dev-libs/a", # no closing paren
+ "( dev-libs/a dev-libs/b", # no closing paren, multiple items
+ "|| ( dev-libs/a ", # trailing whitespace, still unterminated
+ "(dev-libs/a )", # no whitespace after '('
+ "|| (dev-libs/a )", # no whitespace after '(' in an any-of
+ "foo? (dev-libs/a )", # no whitespace after '(' in a conditional
+ "||( dev-libs/a )", # no whitespace after the '||' prefix
+ "foo?( dev-libs/a )", # no whitespace after the 'foo?' prefix
+ "|| dev-libs/a", # any-of with no group at all
+ "foo? dev-libs/a", # conditional with no group at all
+ "|| dev-libs/a dev-libs/b",
+ "( dev-libs/a )dev-libs/b", # no whitespace after ')'
+ "|| (",
+ "foo?",
+ "||",
+ )
+
+ def setUp(self):
+ if _orig_c_dep_parser is None:
+ self.skipTest("_parser extension not available")
+
+ def _reduce(self, depstr, use_c):
+ with _use_c_parser(use_c):
+ return use_reduce(depstr, token_class=Atom, eapi="8", uselist=["foo"])
+
+ def test_both_paths_reject(self):
+ for depstr in self.CASES:
+ with self.subTest(depstr=depstr):
+ with self.assertRaises(InvalidDependString):
+ self._reduce(depstr, use_c=False)
+ with self.assertRaises(InvalidDependString):
+ self._reduce(depstr, use_c=True)
+
+
+class TestCParserWhitespace(TestCase):
+ """The C parser sees the dep string unsplit, so leading, trailing and
+ repeated whitespace is its own business rather than str.split()'s."""
+
+ CASES = (
+ "dev-libs/a ",
+ " dev-libs/a",
+ "\tdev-libs/a\n",
+ "dev-libs/a dev-libs/b",
+ "|| ( dev-libs/a dev-libs/b ) ",
+ "\n|| (\n\tdev-libs/a\n\tdev-libs/b\n)\n",
+ " ",
+ "",
+ )
+
+ def setUp(self):
+ if _orig_c_dep_parser is None:
+ self.skipTest("_parser extension not available")
+
+ def _reduce(self, depstr, use_c):
+ with _use_c_parser(use_c):
+ return use_reduce(depstr, token_class=Atom, eapi="8", matchall=True)
+
+ def test_parity(self):
+ for depstr in self.CASES:
+ with self.subTest(depstr=depstr):
+ py = self._reduce(depstr, use_c=False)
+ c = self._reduce(depstr, use_c=True)
+ ok, msg = _result_equal(c, py)
+ self.assertTrue(ok, f"{depstr!r}: {msg}")
+
+
+class TestCParserInactiveConditionalBodies(TestCase):
+ """The body of an inactive use conditional is not emitted, but it is still
+ parsed with the real grammar (via the skip visitor) so that syntax errors
+ inside it are still reported. Nested groups and nested conditionals inside
+ an inactive body are the interesting cases."""
+
+ def setUp(self):
+ if _orig_c_dep_parser is None:
+ self.skipTest("_parser extension not available")
+
+ def _reduce(self, depstr, use_c, uselist=()):
+ with _use_c_parser(use_c):
+ return use_reduce(depstr, token_class=Atom, eapi="8", uselist=list(uselist))
+
+ VALID = (
+ ("foo? ( ( dev-libs/a ) )", ()),
+ ("foo? ( || ( dev-libs/a dev-libs/b ) )", ()),
+ ("foo? ( bar? ( dev-libs/a ) )", ()),
+ ("foo? ( bar? ( dev-libs/a ) )", ("bar",)),
+ ("!foo? ( bar? ( dev-libs/a ) )", ("foo", "bar")),
+ ("foo? ( !bar? ( dev-libs/a ) )", ()),
+ ("foo? ( bar? ( || ( dev-libs/a dev-libs/b ) ) )", ()),
+ ("foo? ( dev-libs/a ) bar? ( dev-libs/b )", ("bar",)),
+ )
+
+ # Syntax errors that only appear inside a body that is never emitted.
+ INVALID = (
+ ("foo? ( ||( dev-libs/a ) )", ()),
+ ("foo? ( (dev-libs/a ) )", ()),
+ ("foo? ( bar? ( noslash ) )", ()),
+ ("foo? ( bar? ( dev-libs/a )", ()),
+ ("foo? ( >=dev-libs/a )", ()),
+ )
+
+ def test_inactive_bodies_parity(self):
+ for depstr, uselist in self.VALID:
+ with self.subTest(depstr=depstr, uselist=uselist):
+ py = self._reduce(depstr, use_c=False, uselist=uselist)
+ c = self._reduce(depstr, use_c=True, uselist=uselist)
+ ok, msg = _result_equal(c, py)
+ self.assertTrue(ok, f"{depstr!r} uselist={uselist}: {msg}")
+
+ def test_errors_inside_inactive_bodies_still_raise(self):
+ for depstr, uselist in self.INVALID:
+ with self.subTest(depstr=depstr, uselist=uselist):
+ with self.assertRaises(InvalidDependString):
+ self._reduce(depstr, use_c=False, uselist=uselist)
+ with self.assertRaises(InvalidDependString):
+ self._reduce(depstr, use_c=True, uselist=uselist)
+
+
+class TestCAtomObjectProtocol(TestCase):
+ """_parser.Atom's tp_repr/tp_str/tp_hash/tp_richcompare slots."""
+
+ def setUp(self):
+ self._parser = _c_parser()
+ if self._parser is None:
+ self.skipTest("_parser extension not available")
+
+ def _atom(self, s):
+ return self._parser.parse(s, matchall=True)[0]
+
+ def test_str(self):
+ self.assertEqual(
+ str(self._atom(">=dev-libs/a-1:2/3=[x]")), ">=dev-libs/a-1:2/3=[x]"
+ )
+
+ def test_repr(self):
+ self.assertEqual(repr(self._atom("dev-libs/a")), "Atom('dev-libs/a')")
+
+ def test_hash_matches_string(self):
+ self.assertEqual(hash(self._atom("dev-libs/a")), hash("dev-libs/a"))
+
+ def test_hashable_in_containers(self):
+ a, b = self._atom("dev-libs/a"), self._atom("dev-libs/a")
+ self.assertEqual(len({a, b}), 1)
+ self.assertEqual({a: 1}[b], 1)
+
+ def test_equality(self):
+ a, b = self._atom("dev-libs/a"), self._atom("dev-libs/a")
+ c = self._atom("dev-libs/b")
+ self.assertTrue(a == b)
+ self.assertFalse(a != b)
+ self.assertFalse(a == c)
+ self.assertTrue(a != c)
+
+ def test_equality_with_foreign_type(self):
+ a = self._atom("dev-libs/a")
+ for other in ("dev-libs/a", 1, None, Atom("dev-libs/a")):
+ with self.subTest(other=other):
+ self.assertFalse(a == other)
+ self.assertTrue(a != other)
+
+ def test_ordering_is_not_implemented(self):
+ a, b = self._atom("dev-libs/a"), self._atom("dev-libs/b")
+ for op in (operator.lt, operator.le, operator.gt, operator.ge):
+ with self.subTest(op=op.__name__):
+ with self.assertRaises(TypeError):
+ op(a, b)
+
+
+class TestCParserEapiGuard(TestCase):
+ """The C scanner implements the modern (EAPI 5+) atom grammar
+ unconditionally. The use_reduce fast-path guard must therefore only take
+ the C path for EAPIs whose grammar matches -- None (permissive) or
+ slot_operator-capable (EAPI 5+) -- so it never accepts atoms that the
+ pure-Python path rejects under an older EAPI."""
+
+ def setUp(self):
+ if _orig_c_dep_parser is None:
+ self.skipTest("_parser extension not available")
+
+ def _reduce(self, depstr, eapi, use_c):
+ with _use_c_parser(use_c):
+ return use_reduce(
+ depstr, token_class=Atom, matchall=True, eapi=eapi, uselist=[]
+ )
+
+ def test_slot_operator_gate_matches_expectation(self):
+ for eapi in ("0", "1", "4"):
+ self.assertFalse(_get_eapi_attrs(eapi).slot_operator, eapi)
+ for eapi in (None, "5", "6", "7", "8"):
+ self.assertTrue(_get_eapi_attrs(eapi).slot_operator, eapi)
+
+ def test_old_eapi_rejects_modern_syntax(self):
+ # Each string is invalid under the given older EAPI. The Python path
+ # rejects it; the C path is guard-skipped for these EAPIs, so it must
+ # reject identically rather than silently accept.
+ cases = [
+ ("dev-libs/foo:=", "4"), # slot operator: EAPI 5+
+ ("dev-libs/foo:1/2", "4"), # sub-slot: EAPI 5+
+ ("dev-libs/foo[bar]", "1"), # use dep: EAPI 4+
+ ("dev-libs/foo:1", "0"), # slot dep: EAPI 1+
+ ]
+ for depstr, eapi in cases:
+ with self.subTest(depstr=depstr, eapi=eapi):
+ with self.assertRaises(InvalidDependString):
+ self._reduce(depstr, eapi, use_c=False)
+ with self.assertRaises(InvalidDependString):
+ self._reduce(depstr, eapi, use_c=True)
+
+ def test_valid_across_eapis_parity(self):
+ cases = [
+ ("dev-libs/foo", None),
+ ("dev-libs/foo", "0"),
+ ("dev-libs/foo", "8"),
+ ("dev-libs/foo:1", "1"),
+ ("dev-libs/foo:1", "8"),
+ ("dev-libs/foo[bar]", "4"),
+ ("dev-libs/foo[bar]", "8"),
+ ("dev-libs/foo:=", "5"),
+ ("dev-libs/foo:1/2=", "8"),
+ ]
+ for depstr, eapi in cases:
+ with self.subTest(depstr=depstr, eapi=eapi):
+ py = self._reduce(depstr, eapi, use_c=False)
+ c = self._reduce(depstr, eapi, use_c=True)
+ ok, msg = _result_equal(c, py)
+ self.assertTrue(ok, f"{depstr!r} eapi={eapi}: {msg}")
+
+
+class TestKillSwitch(TestCase):
+ """PORTAGE_NATIVE_DEP_PARSER=0 must disable the C path. It is consulted
+ when portage.dep is imported, so this has to run in a fresh process."""
+
+ PROBE = "import portage.dep; print(portage.dep._c_dep_parser is None)"
+
+ def setUp(self):
+ if _orig_c_dep_parser is None:
+ self.skipTest("_parser extension not available")
+
+ def _probe(self, value):
+ env = dict(os.environ)
+ env["PYTHONPATH"] = os.pathsep.join(
+ [str(Path(portage.__file__).parent.parent), env.get("PYTHONPATH", "")]
+ )
+ if value is None:
+ env.pop("PORTAGE_NATIVE_DEP_PARSER", None)
+ else:
+ env["PORTAGE_NATIVE_DEP_PARSER"] = value
+ out = subprocess.run(
+ [sys.executable, "-c", self.PROBE],
+ env=env,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return out.stdout.strip()
+
+ def test_unset_uses_c_parser(self):
+ self.assertEqual(self._probe(None), "False")
+
+ def test_zero_disables_c_parser(self):
+ self.assertEqual(self._probe("0"), "True")
+
+ def test_other_values_do_not_disable(self):
+ # Only the exact string "0" disables it.
+ for value in ("1", "", "no"):
+ with self.subTest(value=value):
+ self.assertEqual(self._probe(value), "False")