loopgate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ """Property-based tests for preferences.preferences.py using Hypothesis.
2
+
3
+ "With Hypothesis, you write tests which should pass for all inputs in whatever range you describe, and let
4
+ Hypothesis randomly choose which of those inputs to check, including edge cases you might not have thought
5
+ about." TESTS THE CODE WITH A RANGE OF INPUTS.
6
+ Hypothesis docs: https://hypothesis.readthedocs.io/
7
+
8
+ Tests that preferences.py checks for names, classes, comprehensions, and continue
9
+
10
+ Hypothesis persistence: Do not set database=None by default. Local runs use Hypothesis's example
11
+ database under .hypothesis/examples, so past failures are replayed first and users can debug them
12
+ quickly. CI automatically uses Hypothesis's built-in `ci` profile, which is stateless and deterministic.
13
+ If a generated input is important, save it as @example(...) or a normal regression test instead of
14
+ relying on the local database. The generated .hypothesis/ directory is gitignored.
15
+
16
+ Test hygiene: keep strategies at module scope. Set max_examples only when a test needs a runtime cap. Do
17
+ not use function-scoped fixtures with @given; patch per-example state inside helper functions instead.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib
23
+ import keyword
24
+ import string
25
+ import sys
26
+ from collections.abc import Callable
27
+ from unittest import mock
28
+
29
+ from hypothesis import assume, given, strategies
30
+
31
+ # preferences.py are optional AST-style checks. Repo-owner can keep or delete.
32
+ preferences_violations: Callable[[str, str], str] | None
33
+ try:
34
+ from preferences.preferences import preferences_violations
35
+ except ImportError:
36
+ preferences_violations = None
37
+
38
+ # Patterns in a deterministic order so hypothesis shrinks toward the first entry predictably.
39
+ IDENTIFIER_START = string.ascii_letters + "_"
40
+ IDENTIFIER_REST = IDENTIFIER_START + string.digits
41
+
42
+
43
+ @strategies.composite
44
+ def identifiers(draw: strategies.DrawFn) -> str:
45
+ """Draw a valid ASCII Python identifier that is not a keyword."""
46
+ first = draw(strategies.sampled_from(IDENTIFIER_START))
47
+ rest = draw(strategies.text(alphabet=IDENTIFIER_REST, max_size=24))
48
+ name = first + rest
49
+ assume(not keyword.iskeyword(name))
50
+ return name
51
+
52
+
53
+ IDENTIFIERS = identifiers()
54
+
55
+
56
+ def flags(source: str, needle: str) -> bool:
57
+ """Whether preferences_violations reports a message containing needle for source."""
58
+ return needle in preferences_violations("m.py", source) if preferences_violations else False
59
+
60
+
61
+ # ------------------------------------------------------- preferences.py-absent fallback (optional module)
62
+
63
+
64
+ def test_module_tolerates_absent_preferences_on_import() -> None:
65
+ """When preferences.preferences.py can't be imported (e.g. human deleted), this module still loads and its
66
+ preferences_violations is None, so its gate tests here keep running. mock.patch.dict maps the module
67
+ name to None (the standard way to make `import` raise ImportError) and auto-restores it; reloading
68
+ this module under that patch exercises the ImportError fallback, then a final reload restores it.
69
+ """
70
+ module = sys.modules[__name__]
71
+ try:
72
+ with mock.patch.dict(sys.modules, {"preferences.preferences": None}):
73
+ reloaded = importlib.reload(module)
74
+ assert reloaded.preferences_violations is None
75
+ finally:
76
+ importlib.reload(module) # restore the real preferences_violations for the rest of the suite
77
+
78
+
79
+ def test_flags_never_calls_preferences_when_it_is_absent() -> None:
80
+ """Deleting preferences.py must not crash flags(). It reports no violation instead.
81
+
82
+ A variable named with a leading underscore breaks the underscore rule. The first assert confirms
83
+ the rule fires on it. The second feeds in that same variable with the module gone and gets no
84
+ violation back, which is only possible if preferences_violations was never called.
85
+ """
86
+ source, rule = "_bad = 1\n", "starts with underscore"
87
+ assert flags(source, rule) is True
88
+ with mock.patch.object(sys.modules[__name__], "preferences_violations", None):
89
+ assert flags(source, rule) is False
90
+
91
+
92
+ # --------------------------------------------------------------------- underscore-lead identifier rule
93
+
94
+
95
+ @given(name=IDENTIFIERS)
96
+ def test_underscore_lead_flagged_iff_leading_underscore_not_dunder(name: str) -> None:
97
+ """An assignment target trips the underscore rule IFF it has a prohibited leading underscore.
98
+ Covers the whole identifier domain, including dunders and the exempt lone '_', in one property.
99
+ """
100
+ expected = name != "_" and name.startswith("_") and not name.startswith("__") and not name.endswith("__")
101
+ assert flags(f"{name} = 1\n", "starts with underscore") is expected
102
+
103
+
104
+ @given(name=IDENTIFIERS)
105
+ def test_underscore_rule_holds_for_function_and_argument_names(name: str) -> None:
106
+ """The same underscore rule applies to function names and argument names, not just assignments."""
107
+ assume(not name.endswith("__")) # keep dunder methods/args (__init__ etc.) out of this slice
108
+ expected = name != "_" and name.startswith("_") and not name.startswith("__")
109
+ assert flags(f"def {name}():\n return 1\n", "starts with underscore") is expected
110
+ assert flags(f"def f({name}):\n return {name}\n", "starts with underscore") is expected
111
+
112
+
113
+ # ------------------------------------------------------------------------------- pointless-class rule
114
+
115
+
116
+ @strategies.composite
117
+ def class_source(draw: strategies.DrawFn) -> tuple[str, bool]:
118
+ """Draw a class definition varying base/decorator/keyword presence and method count.
119
+
120
+ Args:
121
+ draw: Hypothesis draw callable.
122
+
123
+ Returns:
124
+ (source, should_flag) where should_flag is the documented intent: trip IFF the class has no
125
+ base, no decorator, no keyword, and at most one method.
126
+ """
127
+ has_base = draw(strategies.booleans())
128
+ has_decorator = draw(strategies.booleans())
129
+ has_keyword = draw(strategies.booleans())
130
+ method_count = draw(strategies.integers(min_value=0, max_value=3))
131
+
132
+ decorator = "@deco\n" if has_decorator else ""
133
+ header_bits = (["Base"] if has_base else []) + (["metaclass=type"] if has_keyword else [])
134
+ header = f"({', '.join(header_bits)})" if header_bits else ""
135
+ body = "".join(f" def m{i}(self):\n return {i}\n" for i in range(method_count)) or " x = 1\n"
136
+ source = f"{decorator}class C{header}:\n{body}"
137
+
138
+ should_flag = not has_base and not has_decorator and not has_keyword and method_count <= 1
139
+ return source, should_flag
140
+
141
+
142
+ @given(case=class_source())
143
+ def test_pointless_class_flagged_iff_plain_and_at_most_one_method(case: tuple[str, bool]) -> None:
144
+ """A class trips the pointless-class rule IFF it is plain (no base/decorator/keyword) with <= 1
145
+ method. Any base, decorator, keyword, or a second method exempts it.
146
+ """
147
+ source, should_flag = case
148
+ assert flags(source, "no base, decorator, or behavior") is should_flag
149
+
150
+
151
+ # ------------------------------------------------------------------------ complex-comprehension rule
152
+
153
+
154
+ @strategies.composite
155
+ def comprehension_source(draw: strategies.DrawFn) -> tuple[str, bool]:
156
+ """Draw a list comprehension with a chosen generator count and which generator (if any) filters.
157
+
158
+ Args:
159
+ draw: Hypothesis draw callable.
160
+
161
+ Returns:
162
+ (source, should_flag) where should_flag is the documented intent: trip IFF there is more than
163
+ one generator AND at least one generator carries an `if`. Crucially the filtered generator may
164
+ be a LATER one, exercising the check's early-return-on-first-match loop.
165
+ """
166
+ generator_count = draw(strategies.integers(min_value=1, max_value=3))
167
+ # -1 means "no if on any generator"; otherwise the index of the single generator that filters.
168
+ if_on = draw(strategies.integers(min_value=-1, max_value=generator_count - 1))
169
+
170
+ clauses: list[str] = []
171
+ for index in range(generator_count):
172
+ clause = f"for v{index} in xs{index}"
173
+ if index == if_on:
174
+ clause += f" if v{index}"
175
+ clauses.append(clause)
176
+ source = f"[v0 {' '.join(clauses)}]\n"
177
+
178
+ should_flag = generator_count > 1 and if_on != -1
179
+ return source, should_flag
180
+
181
+
182
+ @given(case=comprehension_source())
183
+ def test_complex_comprehension_flagged_iff_multi_generator_with_filter(case: tuple[str, bool]) -> None:
184
+ """A comprehension trips IFF it has multiple generators AND at least one has an `if` -- regardless of
185
+ WHICH generator carries the `if`. The later-generator case guards the check's early return, which
186
+ scans generators in order and returns on the first one that filters.
187
+ """
188
+ source, should_flag = case
189
+ assert flags(source, "Overly complex comprehension") is should_flag
190
+
191
+
192
+ # --------------------------------------------------------------------------- chaotic-continue rule
193
+
194
+
195
+ @strategies.composite
196
+ def nested_continue_source(draw: strategies.DrawFn) -> str:
197
+ """Draw a `continue` wrapped in an outer `for` plus TWO-to-four more if/for blocks.
198
+
199
+ The rule allows one `if` guard directly inside a loop (`for: if: continue`), so to always be
200
+ over-nested we stack at least two blocks below the outer loop.
201
+
202
+ Args:
203
+ draw: Hypothesis draw callable.
204
+
205
+ Returns:
206
+ Source whose `continue` sits at least two if/for blocks below its enclosing loop, so the
207
+ over-nesting rule always flags it.
208
+ """
209
+ depth = draw(strategies.integers(min_value=2, max_value=4))
210
+ blocks = draw(strategies.lists(strategies.sampled_from(["if cond", "for i in xs"]), min_size=depth, max_size=depth))
211
+
212
+ lines = ["for outer in items:"] # an outer loop the continue always belongs to
213
+ indent = " "
214
+ for block in blocks:
215
+ lines.append(f"{indent}{block}:")
216
+ indent += " "
217
+ lines.append(f"{indent}continue")
218
+ return "\n".join(lines) + "\n"
219
+
220
+
221
+ @given(source=nested_continue_source())
222
+ def test_continue_nested_under_stacked_blocks_is_flagged(source: str) -> None:
223
+ """A `continue` stacked two or more if/for blocks below its enclosing loop is flagged as overly
224
+ nested, whatever mix of if/for those blocks are.
225
+ """
226
+ assert flags(source, "Overly-nested 'continue'")
227
+
228
+
229
+ def test_single_if_guard_in_a_loop_is_not_flagged() -> None:
230
+ """The common, readable `for ...: if ...: continue` (one if guard in one loop) is NOT over-nested."""
231
+ assert not flags("for i in items:\n if skip:\n continue\n", "Overly-nested 'continue'")
232
+
233
+
234
+ def test_shallow_continue_in_single_loop_is_not_flagged() -> None:
235
+ """Control (example, not property): a `continue` directly in one `for` -- parent For, grandparent
236
+ module -- is not overly nested, so it is not flagged.
237
+ """
238
+ assert not flags("for x in items:\n continue\n", "Overly-nested 'continue'")
239
+
240
+
241
+ def test_continue_in_while_loop_is_flagged() -> None:
242
+ """Control (example): a `continue` anywhere inside a while loop is flagged (freeze risk), a separate
243
+ branch from the nested-if detection.
244
+ """
245
+ assert flags("while cond:\n continue\n", "while loop banned")