From b46056560539a5277b0b51dea78b88679c1cb315 Mon Sep 17 00:00:00 2001 From: Antonio Cordero Balcazar Date: Mon, 6 Apr 2026 10:44:25 +0200 Subject: [PATCH] * Support for ranges in wildcard positional filters * Some Copilot instructions. --- .../python-compat.instructions.md | 37 ++++++++ .github/instructions/tests.instructions.md | 87 +++++++++++++++++++ docs/SYNTAX.md | 3 +- grammar.lark | 8 +- ppp.py | 58 +++++++------ tests/tests_choices.py | 9 +- tests/tests_cleanup.py | 10 +-- tests/tests_commands.py | 9 +- tests/tests_host.py | 10 +-- tests/tests_performance.py | 9 +- tests/tests_stn.py | 9 +- tests/tests_variables.py | 20 +++-- tests/tests_variants.py | 9 +- tests/tests_wildcards.py | 16 ++-- 14 files changed, 208 insertions(+), 86 deletions(-) create mode 100644 .github/instructions/python-compat.instructions.md create mode 100644 .github/instructions/tests.instructions.md diff --git a/.github/instructions/python-compat.instructions.md b/.github/instructions/python-compat.instructions.md new file mode 100644 index 0000000..97ae083 --- /dev/null +++ b/.github/instructions/python-compat.instructions.md @@ -0,0 +1,37 @@ +--- +description: "Use when writing, editing, or reviewing Python code. Enforces Python 3.10 compatibility — avoid syntax and stdlib features introduced in 3.11 or later." +applyTo: "**/*.py" +--- +# Python 3.10 Compatibility + +All Python code must be compatible with Python 3.10. Do not use language features or standard-library additions introduced in 3.11 or later. + +## Forbidden (3.11+) + +| Avoid | Use instead | +|-------|-------------| +| `tomllib` (stdlib) | `tomli` (third-party) or conditional `try/except ImportError` | +| `typing.Self` | `TypeVar("Self", bound="ClassName")` or `from __future__ import annotations` | +| `typing.Never` | `typing.NoReturn` | +| `typing.TypeVarTuple`, `typing.Unpack` | `typing_extensions` backports | +| `StrEnum` | `class MyEnum(str, enum.Enum)` | +| `except*` / `ExceptionGroup` | Not available; raise/catch normally | +| `asyncio.TaskGroup`, `asyncio.timeout()` | `asyncio.gather()` / `asyncio.wait_for()` | + +## Forbidden (3.12+) + +| Avoid | Use instead | +|-------|-------------| +| `type X = ...` (type alias statement) | `X: TypeAlias = ...` with `typing.TypeAlias` | +| `def f[T](...)` / `class C[T]` generic syntax | `TypeVar` + `Generic[T]` | +| `@typing.override` | Omit or use comment | +| `itertools.batched()` | Manual chunking or `more-itertools` | + +## Safe to use (available in 3.10) + +- `match`/`case` structural pattern matching +- `X | Y` union type syntax in annotations (e.g., `int | None`) +- `typing.TypeAlias`, `typing.ParamSpec` +- `list[int]`, `dict[str, int]` — built-in generic aliases +- `zip(..., strict=True)` +- `str.removeprefix()` / `str.removesuffix()` diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md new file mode 100644 index 0000000..89a011b --- /dev/null +++ b/.github/instructions/tests.instructions.md @@ -0,0 +1,87 @@ +--- +description: "Use when writing, editing, or reviewing test files. Enforces unittest patterns used in this project: TestCase inheritance, base class setup, process() helper, and naming conventions." +applyTo: "tests/**/*.py" +--- +# Test Conventions + +All tests use the standard `unittest` module. Do **not** use pytest-specific features (fixtures, `@pytest.mark`, `conftest.py`, etc.). + +## File & Class Naming + +- One test file per concern, named `tests_{category}.py` +- One test class per file inheriting from `TestPromptPostProcessorBase`: + +```python +import unittest +from tests.base_tests import TestPromptPostProcessorBase + +class TestMyFeature(TestPromptPostProcessorBase): + + def setUp(self): + super().setUp(enable_file_logging=False) +``` + +## Test Method Naming + +``` +test_{2-3 letter category prefix}_{short_description} +``` + +Examples: `test_cl_simple`, `test_ch_choices`, `test_wc_ignore` + +## Running a Test Case via `self.process()` + +Use the `process()` helper from the base class — never instantiate `PromptPostProcessor` directly in test methods. + +```python +def test_cl_simple(self): + """simple cleanup""" + self.process( + "input prompt", # positive prompt + "", # negative prompt + PromptPair("expected output", ""), # expected result + ) +``` + +### `process()` Signature (key parameters) + +| Parameter | Type | Notes | +|-----------|------|-------| +| `input_prompt` | `str` | Positive prompt input | +| `input_negative_prompt` | `str` | Negative prompt input | +| `expected_output` | `PromptPair \| list[PromptPair]` | Single or multiple valid outputs | +| `seed` | `int` | Optional, defaults to fixed seed | +| `ppp` | `PromptPostProcessor \| str \| None` | Pass `"nocup"` to skip creation | +| `interrupted` | `bool` | Expected interrupt flag | +| `output_variables` | `dict[str, str] \| None` | Variables to validate after processing | + +## Assertions + +Use `assertEqual` with a descriptive message string: + +```python +self.assertEqual(result, expected, "Descriptive failure message") +self.assertIn(key, container, f"Key '{key}' not found in output") +``` + +Do not use bare `assert` statements. + +## Default Options & Environment + +Override `self.defopts` or `self.def_env_info` to pass non-default options — do not hardcode option dicts from scratch: + +```python +def test_cl_custom(self): + """cleanup with custom separator""" + opts = {**self.defopts, "ppp_stn_separator": " | "} + self.process("a, , b", "", PromptPair("a | b", ""), ppp_opts=opts) +``` + +## Entry Point + +Every test file must start with: + +```python +if __name__ == "__main__": + raise SystemExit("This script must not be run directly") +``` diff --git a/docs/SYNTAX.md b/docs/SYNTAX.md index d05e0e6..5c73226 100644 --- a/docs/SYNTAX.md +++ b/docs/SYNTAX.md @@ -93,7 +93,7 @@ Wildcards cannot be used inside an extranetwork tag (because some lora names con The filter can be used to filter specific choices from the wildcard. The filtering works before applying the choice conditions (if any). The surrounding quotes can be single or double. -The filter is a comma separated list of an integer (positional choice index, zero-based) or choice label. You can also compound them with `+`. That is, the comma separated items act as an OR and the `+` inside them as an AND. Using labels can simplify the definitions of complex wildcards where you want to have direct access to specific choices on occasion (you don't need to create wildcards for each individual choice). +The filter is a comma separated list of an integer/range (positional choice index, zero-based) or choice label. You can also compound them with `+`. That is, the comma separated items act as an OR and the `+` inside them as an AND. Using labels can simplify the definitions of complex wildcards where you want to have direct access to specific choices on occasion (you don't need to create wildcards for each individual choice). There are some additional formats when using filters. @@ -112,6 +112,7 @@ These are examples of formats you can use to insert a wildcard: | --------- | ------ | | `__wildcard__` | select 1 choice | | `__path/wildcard'0'__` | select the first choice | +| `__path/wildcard'1-2'__` | select the second or third choice | | `__path/wildcard'label'__` | select the choices with label "label" | | `__path/wildcard'0,label1,label2'__` | select the first choice and those with labels "label1" or "label2" | | `__path/wildcard'0,label1+label2'__` | select the first choice and those with both labels "label1" and "label2" | diff --git a/grammar.lark b/grammar.lark index 3f36d3e..2c2e843 100644 --- a/grammar.lark +++ b/grammar.lark @@ -5,7 +5,8 @@ STRING: /("(?!"").*?(? 1 and filter_specifier is not None - and any(x.isdecimal() for x in filter_specifier) + and any(y[0].isdecimal() for x in filter_specifier for y in x) ): self.__ppp.logger.warning( f"Using a globbing wildcard '{escape_single_quotes(wildcard_key)}' with positional index filters is not recommended!" @@ -2745,6 +2737,20 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in t2 = time.monotonic_ns() self.__debug_end("wildcard", start_result, t2 - t1, f"'{escape_single_quotes(wc)}'") + def __extract_filter_specifiers(self, filters: lark.Tree) -> list[list[str]]: + filter_specifier = [] + for or_ in filters.children: + for and_ in or_.children: + label = and_.children[0] + if isinstance(label, lark.Token): + # it's a literal, we can use it directly + filter_specifier.append([label.value]) + else: + # it's a variable, we need to evaluate it + v = self.__visit(label, False, True) + filter_specifier.append([v]) + return filter_specifier + def choices(self, tree: lark.Tree): """ Process a choices construct in the tree. diff --git a/tests/tests_choices.py b/tests/tests_choices.py index 2d8f6ce..fc4003a 100644 --- a/tests/tests_choices.py +++ b/tests/tests_choices.py @@ -1,8 +1,9 @@ -import unittest - from ppp import PromptPostProcessor # pylint: disable=import-error from .base_tests import PromptPair, TestPromptPostProcessorBase +if __name__ == "__main__": + raise SystemExit("This script must not be run directly") + class TestChoices(TestPromptPostProcessorBase): @@ -95,7 +96,3 @@ class TestChoices(TestPromptPostProcessorBase): PromptPair("ch_two", ""), ppp="nocup", ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tests_cleanup.py b/tests/tests_cleanup.py index c6472ae..4653435 100644 --- a/tests/tests_cleanup.py +++ b/tests/tests_cleanup.py @@ -1,10 +1,10 @@ -import unittest - from ppp import PromptPostProcessor # pylint: disable=import-error - from .base_tests import PromptPair, TestPromptPostProcessorBase +if __name__ == "__main__": + raise SystemExit("This script must not be run directly") + class TestCleanup(TestPromptPostProcessorBase): def setUp(self): # pylint: disable=arguments-differ @@ -126,7 +126,3 @@ class TestCleanup(TestPromptPostProcessorBase): ), ppp="nocup", ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tests_commands.py b/tests/tests_commands.py index edd0eec..110b7f1 100644 --- a/tests/tests_commands.py +++ b/tests/tests_commands.py @@ -1,8 +1,9 @@ -import unittest - from ppp import PromptPostProcessor # pylint: disable=import-error from .base_tests import PromptPair, TestPromptPostProcessorBase +if __name__ == "__main__": + raise SystemExit("This script must not be run directly") + class TestCommands(TestPromptPostProcessorBase): @@ -342,7 +343,3 @@ class TestCommands(TestPromptPostProcessorBase): self.extranetwork_maps_obj, ), ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tests_host.py b/tests/tests_host.py index bff3fac..a04b801 100644 --- a/tests/tests_host.py +++ b/tests/tests_host.py @@ -1,9 +1,9 @@ -import unittest - from ppp import PromptPostProcessor # pylint: disable=import-error - from .base_tests import PromptPair, TestPromptPostProcessorBase +if __name__ == "__main__": + raise SystemExit("This script must not be run directly") + class TestHosts(TestPromptPostProcessorBase): @@ -436,7 +436,3 @@ class TestHosts(TestPromptPostProcessorBase): ), interrupted=True, ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tests_performance.py b/tests/tests_performance.py index 1b73581..668b643 100644 --- a/tests/tests_performance.py +++ b/tests/tests_performance.py @@ -1,7 +1,8 @@ -import unittest - from .base_tests import PromptPair, TestPromptPostProcessorBase +if __name__ == "__main__": + raise SystemExit("This script must not be run directly") + class TestPerformance(TestPromptPostProcessorBase): @@ -72,7 +73,3 @@ class TestPerformance(TestPromptPostProcessorBase): PromptPair(large_prompt, ""), ppp="nocup", ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tests_stn.py b/tests/tests_stn.py index 4692f45..c755ffa 100644 --- a/tests/tests_stn.py +++ b/tests/tests_stn.py @@ -1,7 +1,8 @@ -import unittest - from .base_tests import PromptPair, TestPromptPostProcessorBase +if __name__ == "__main__": + raise SystemExit("This script must not be run directly") + class TestSendToNegative(TestPromptPostProcessorBase): @@ -121,7 +122,3 @@ class TestSendToNegative(TestPromptPostProcessorBase): # "[neg1[|neg12|||neg15]|neg2|neg3]", # expected output if the constructs were unified ), ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tests_variables.py b/tests/tests_variables.py index eecab20..aafce6e 100644 --- a/tests/tests_variables.py +++ b/tests/tests_variables.py @@ -1,8 +1,9 @@ -import unittest - from ppp import PromptPostProcessor # pylint: disable=import-error from .base_tests import PromptPair, TestPromptPostProcessorBase +if __name__ == "__main__": + raise SystemExit("This script must not be run directly") + class TestVariables(TestPromptPostProcessorBase): @@ -54,6 +55,17 @@ class TestVariables(TestPromptPostProcessorBase): ), ) + # Variable in extranetworks + + def test_variable_in_extranetwork(self): + self.process( + PromptPair( + "${f=filename}${w=0.5}", + "", + ), + PromptPair("", ""), + ) + # Variable nesting tests def test_var_nested_1(self): # variable default nested in variable set @@ -199,7 +211,3 @@ class TestVariables(TestPromptPostProcessorBase): self.extranetwork_maps_obj, ), ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tests_variants.py b/tests/tests_variants.py index 8ec1bc0..2e7ef26 100644 --- a/tests/tests_variants.py +++ b/tests/tests_variants.py @@ -1,8 +1,9 @@ -import unittest - from ppp import PromptPostProcessor # pylint: disable=import-error from .base_tests import PromptPair, TestPromptPostProcessorBase +if __name__ == "__main__": + raise SystemExit("This script must not be run directly") + class TestModelVariants(TestPromptPostProcessorBase): @@ -69,7 +70,3 @@ class TestModelVariants(TestPromptPostProcessorBase): self.extranetwork_maps_obj, ), ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tests_wildcards.py b/tests/tests_wildcards.py index 4735469..1cf041e 100644 --- a/tests/tests_wildcards.py +++ b/tests/tests_wildcards.py @@ -1,8 +1,9 @@ -import unittest - from ppp import PromptPostProcessor from .base_tests import PromptPair, TestPromptPostProcessorBase +if __name__ == "__main__": + raise SystemExit("This script must not be run directly") + class TestWildcards(TestPromptPostProcessorBase): @@ -207,6 +208,13 @@ class TestWildcards(TestPromptPostProcessorBase): ppp="nocup", ) + def test_wc_wildcard_filter_index_range(self): # wildcard with positional index range filter + self.process( + PromptPair("the choice is: __yaml/wildcard2'2-3'__", ""), + PromptPair("the choice is: choice3-choice3", ""), + ppp="nocup", + ) + def test_wc_wildcard_filter_label(self): # wildcard with label filter self.process( PromptPair("the choice is: __yaml/wildcard2'label1'__", ""), @@ -411,7 +419,3 @@ class TestWildcards(TestPromptPostProcessorBase): PromptPair("the choices are: choice1-choice3-choice1 choice3- choice2 - choice2 choice3", ""), ppp="nocup", ) - - -if __name__ == "__main__": - unittest.main()