alternative 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,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: alternative
3
+ Version: 0.1.0
4
+ Summary: A library to help with the management of alternative implementations of functions
5
+ Author-email: Oliver Bristow <evilumbrella+github@gmail.com>
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: pytest>=8.3.5
8
+ Description-Content-Type: text/markdown
9
+
10
+ [![PyPi Package](https://badge.fury.io/py/alternative.svg)](https://pypi.org/project/alternative/) [![Build Status](https://github.com/Code0x58/alternative/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/Code0x58/alternative/actions/workflows/ci.yml) [![Coverage Report](https://codecov.io/gh/Code0x58/alternative/branch/master/graph/badge.svg)](https://codecov.io/gh/Code0x58/alternative)
11
+
12
+ This is a library to help with the management of alternative implementations of functions, as you may have when
13
+ optimising.
14
+
15
+ ```python
16
+ import alternative
17
+
18
+
19
+ @alternative.reference
20
+ def constant_number():
21
+ return 1
22
+
23
+
24
+ @constant_number.add(default=True)
25
+ def alternative_constant_number():
26
+ return 2
27
+
28
+
29
+ @constant_number.add
30
+ def unused_alternative_constant_number():
31
+ return 3
32
+
33
+
34
+ # the reference implementation is substituted with the default implementation - if no default was specified, the reference would have been used
35
+ assert constant_number() == 2
36
+ # alternative implementations still act like themselves
37
+ assert unused_alternative_constant_number() == 3
38
+
39
+ # TODO: mash in measure + benchmark + equality tests from examples
40
+ ```
41
+
42
+ The library tries to avoid unpleasant surprises that may come from import orders or maintenance issues:
43
+ * the implementation that is used cannot be changed once it has been used
44
+ * implementations cannot be added once they have been inspected: reduces the changes of tests only covering a subset of
45
+
46
+ Running with `ALTERNATIVE_DEBUG=1` as an environment variable will result in the library recording which lines were
47
+ responsible for critical state changes like the point that a default implementation was selected or the implementations
48
+ were inspected. These are then used in error messages which can be used to resolve stateful issues.
49
+
50
+ For how this can tie in with equivalence checks and benchmarking in pytest, see the examples directory.
@@ -0,0 +1,4 @@
1
+ alternative.py,sha256=VHJJV4wGLWztgTU3SC1gtHTY0KlPsvfYTAstyuPLQgY,14965
2
+ alternative-0.1.0.dist-info/METADATA,sha256=pJSIQaq5rzFZVFfr8mHeEU1e7ipaMG1hOd29r6rnrkI,2188
3
+ alternative-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
+ alternative-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
alternative.py ADDED
@@ -0,0 +1,404 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import enum
5
+ import inspect
6
+ import os
7
+ from functools import wraps, lru_cache
8
+ from typing import Callable
9
+ from typing import cast, overload
10
+
11
+
12
+ DEBUG = os.environ.get("ALTERNATIVE_DEBUG", "0").lower() in (
13
+ "1",
14
+ "yes",
15
+ "y",
16
+ "true",
17
+ "t",
18
+ )
19
+
20
+
21
+ class _UNDEFINED: ...
22
+
23
+
24
+ UNDEFINED = _UNDEFINED()
25
+
26
+ type ImplementationSig[**P, R] = Callable[P, R] | Implementation[P, R]
27
+ type AlternativesWrapper[**P, R] = Callable[[ImplementationSig], Alternatives[P, R]]
28
+ type ImplementationWrapper[**P, R] = Callable[[ImplementationSig], Implementation[P, R]]
29
+
30
+
31
+ class AlternativeError(Exception):
32
+ """Base class for all alternative errors."""
33
+
34
+
35
+ class AddTooLate(AlternativeError):
36
+ """Cannot add implementations after the alternatives have been invoked."""
37
+
38
+
39
+ class MultipleDefaults(AlternativeError):
40
+ """Cannot set the default implementation more than once."""
41
+
42
+
43
+ def get_caller_path() -> str | None:
44
+ """
45
+ Return 'module.QualName (file.py:line)' pointing to the line
46
+ that invoked the caller of this function.
47
+ Falls back to '<unknown module>.<unknown> (<unknown location>)'
48
+ if there is no two-up Python frame (e.g. called directly from C embedding).
49
+ """
50
+ frame = inspect.currentframe()
51
+ # Walk back two frames: 0=this, 1=caller, 2=caller of caller
52
+ if not frame or not frame.f_back:
53
+ caller = None # no two-up frame
54
+ else:
55
+ caller = frame.f_back.f_back
56
+
57
+ # walk though any frames that are in the current file as they will not be helpful
58
+ while caller is None or caller.f_code.co_filename == __file__:
59
+ # a bit of a jiggly approach of handling caller being None to make type checking easier and help coverage
60
+ if caller:
61
+ caller = caller.f_back
62
+ if caller is None:
63
+ return "<unknown module>.<unknown> (<unknown location>)"
64
+ code = caller.f_code
65
+ module = caller.f_globals.get("__name__", "<unknown module>")
66
+ qualname = getattr(code, "co_qualname", code.co_name)
67
+
68
+ filename = code.co_filename or "<unknown file>"
69
+ lineno = caller.f_lineno or "?"
70
+ location = f"{filename}:{lineno}"
71
+
72
+ return f"{module}.{qualname} ({location})"
73
+
74
+
75
+ def maybe_get_caller_path() -> str | None:
76
+ """Return the call site if DEBUG is True, otherwise None."""
77
+ if DEBUG:
78
+ return get_caller_path()
79
+ return None
80
+
81
+
82
+ class Mutability(enum.IntEnum):
83
+ """The mutability of an Alternatives object, for providing consistency.
84
+
85
+ The general assumption is that all of these levels should be used for good practice/avoiding surprises. Reducing the
86
+ maximum effective level allows some flexibility, but probably shouldn't be used and is likely to be removed.
87
+
88
+ TODO: deprecate the setting of the maximum effective level
89
+ TODO: on freezing, self __call__ to underlying callable to speed things up
90
+ """
91
+
92
+ MUTABLE = 0
93
+ """Implementations can be added and a default can be set - the initial state"""
94
+ FROZEN_IMPLEMENTATION = 1
95
+ """Default implementations cannot be added or set - state after the default is set"""
96
+ FROZEN = 2
97
+ """No new implementations can be added - state after the first invocation."""
98
+ # TODO: consider having a flag for "always allow new implementations" or MAX_EFFECTIVE_LEVEL with default that can
99
+ # be set on construction
100
+
101
+
102
+ class Alternatives[**P, R]:
103
+ def __init__(self, implementation: Callable[P, R], *, default: bool = False):
104
+ imp = Implementation(self, implementation)
105
+ self.reference = imp
106
+ # tracks the active implementation
107
+ self._default: Implementation[P, R] | None = None
108
+ self._debug_default: str | None = None
109
+ self._invoked = False
110
+ self._debug_invoked_site: str | None = None
111
+ # tracks the use of the set should be
112
+ self._enumerated = False
113
+ self._debug_invoked_site: str | None = None
114
+
115
+ self._callable: Callable[P, R] | None = None
116
+ self._debug_callable_used: str | None = None
117
+
118
+ # beware the order of this depends on the sequence of imports, so may vary between entrypoints
119
+ self._implementations: list[Implementation[P, R]] = []
120
+ self._implementations_used: bool = False
121
+ """indicates if the list of implementations has been used though the external API"""
122
+ self._debug_implementations_used: str | None = None
123
+
124
+ self.add(imp, default=default)
125
+
126
+ @overload
127
+ def add(
128
+ self, implementation: _UNDEFINED = UNDEFINED, *, default: bool = False
129
+ ) -> ImplementationWrapper[P, R]: ...
130
+ @overload
131
+ def add(
132
+ self, implementation: ImplementationSig[P, R], *, default: bool = False
133
+ ) -> Implementation[P, R]: ...
134
+
135
+ def add(
136
+ self,
137
+ implementation=UNDEFINED,
138
+ *,
139
+ default=False,
140
+ ) -> Implementation[P, R] | ImplementationWrapper[P, R]:
141
+ if self._implementations_used:
142
+ # avoid surprises from the implementation changing, which should be unexpected
143
+ if DEBUG:
144
+ msg = f"added implementation after first invocation at {self._debug_implementations_used}"
145
+ else:
146
+ msg = None
147
+ raise AddTooLate(msg)
148
+
149
+ if isinstance(implementation, _UNDEFINED):
150
+ # FIXME: handle when implementation is for a different set of alternatives
151
+ def wrapper(
152
+ implementation: ImplementationSig[P, R],
153
+ ) -> Implementation[P, R]:
154
+ return self.add(implementation, default=default)
155
+
156
+ return cast(ImplementationWrapper[P, R], wrapper)
157
+
158
+ if not isinstance(implementation, Implementation):
159
+ imp = Implementation(self, implementation)
160
+ else:
161
+ imp = Implementation(self, implementation.implementation)
162
+
163
+ if default:
164
+ if self._default is not None:
165
+ # only allow explicitly setting the default implementation once
166
+ if DEBUG:
167
+ msg = f"first default was specified at {self._debug_default}"
168
+ else:
169
+ msg = None
170
+ raise MultipleDefaults(msg)
171
+ # there is the AddTooLate guard above which stops setting of a default after invocation - see test_default_after_invocation
172
+ self._default = imp
173
+ self._debug_default = maybe_get_caller_path()
174
+
175
+ self._implementations.append(imp)
176
+ return imp
177
+
178
+ @property
179
+ def callable(self) -> Callable[P, R]:
180
+ """Return the active implementation.
181
+
182
+ Setting the default implementation is disabled after this is accessed."""
183
+ if self._callable is None:
184
+ # finalise the callable
185
+ if self._default:
186
+ self._callable = self._default.implementation
187
+ else:
188
+ self._callable = self.reference
189
+ self._debug_callable_used = maybe_get_caller_path()
190
+ self.__call__ = self._callable
191
+ # access the list of implementations to freeze them
192
+ assert self.implementations
193
+ return self._callable
194
+
195
+ @property
196
+ def implementations(self) -> list[Implementation[P, R]]:
197
+ if not self._implementations_used:
198
+ self._implementations_used = True
199
+ self._debug_implementations_used = maybe_get_caller_path()
200
+ return self._implementations
201
+
202
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
203
+ # this method will only be called at most once as self.callable overwrites self.__call__
204
+ return self.callable(*args, **kwargs)
205
+
206
+ def measure[M](
207
+ self, /, operator: Callable[[R], M], *args: P.args, **kwargs: P.kwargs
208
+ ) -> dict[Implementation[P, R], M]:
209
+ """Invoke each implementation with the given parameters, then evaluate their results with the operator.
210
+
211
+ This is useful when comparing implementations that have different results, which can be compared by some cost.
212
+ For example, this can be used to compare the complexity of quantum circuits that implement the same function.
213
+
214
+ The results are sorted by the operator's result if the result is sortable (i.e. does not raise TypeError when
215
+ __lt__(a,b) is called); otherwise they are returned in the order of the implementations.
216
+ """
217
+ result = {
218
+ i: operator(i.implementation(*args, **kwargs)) for i in self.implementations
219
+ }
220
+ try:
221
+ # try to sort the dictionary by the measurements
222
+ return dict(sorted(result.items(), key=lambda x: x[1]))
223
+ except TypeError:
224
+ return result
225
+
226
+ @overload
227
+ def pytest_parametrize(
228
+ self,
229
+ test: _UNDEFINED = UNDEFINED,
230
+ *,
231
+ only_default: bool = False,
232
+ ): ...
233
+ @overload
234
+ def pytest_parametrize(
235
+ self,
236
+ test: Callable,
237
+ *,
238
+ only_default: bool = False,
239
+ ): ...
240
+ def pytest_parametrize(
241
+ self,
242
+ test=UNDEFINED,
243
+ *,
244
+ only_default: bool = False,
245
+ ):
246
+ """Decorator to parametrise a test function with implementations - always includes the reference implementation.
247
+
248
+ :param test: Test function to wrap - this is elided if using the decorator syntax.
249
+ :parameter only_default: Only include the reference and default implementations. If False, include all implementations.
250
+ """
251
+ import pytest
252
+
253
+ if isinstance(test, _UNDEFINED):
254
+
255
+ def inner(f: Callable):
256
+ return self.pytest_parametrize(f, only_default=only_default)
257
+
258
+ return inner
259
+
260
+ # FIXME: pull out this block into a function to test that implementations is made correctly
261
+ # + make sure that all arguments are passed through correctly
262
+ if only_default:
263
+ reference_implementation = self.reference.implementation
264
+ default_implementation = self.callable
265
+ implementations = [reference_implementation]
266
+ if default_implementation is not reference_implementation:
267
+ implementations.append(default_implementation)
268
+ else:
269
+ implementations = [i.implementation for i in self.implementations]
270
+
271
+ @pytest.mark.parametrize("implementation", implementations)
272
+ @wraps(test)
273
+ def inner(*args, **kwargs):
274
+ return test(*args, **kwargs)
275
+
276
+ return inner
277
+
278
+ @overload
279
+ def pytest_parametrize_pairs(
280
+ self,
281
+ test: _UNDEFINED = UNDEFINED,
282
+ *,
283
+ n_cache: int | None = 0,
284
+ double_reference: bool = False,
285
+ only_default: bool = False,
286
+ ): ...
287
+ @overload
288
+ def pytest_parametrize_pairs(
289
+ self,
290
+ test: Callable,
291
+ *,
292
+ n_cache: int | None = 0,
293
+ double_reference: bool = False,
294
+ only_default: bool = False,
295
+ ): ...
296
+
297
+ def pytest_parametrize_pairs(
298
+ self,
299
+ test=UNDEFINED,
300
+ *,
301
+ n_cache=0,
302
+ double_reference=False,
303
+ only_default=False,
304
+ ):
305
+ """Decorator to parametrise a test function with the reference and alternative implementations.
306
+
307
+ :parameter test: Inner pytest function to parameterise with reference and alternative implementations - this is elided if using the decorator syntax.
308
+ :parameter n_cache: Passed to lru_cache which wraps the reference implementation. Set to non-0 values for
309
+ it to actually cache.
310
+ :parameter double_reference: If True, the reference implementation will be included in the implementations. This
311
+ can be used for sanity-checking equivalence tests.
312
+ :parameter only_default: If True, only the default implementation will be used as the other implementation. If
313
+ double_reference is True, the reference implementation will be included as well.
314
+ """
315
+ import pytest
316
+
317
+ if isinstance(test, _UNDEFINED):
318
+
319
+ def inner(f: Callable):
320
+ return self.pytest_parametrize_pairs(
321
+ f,
322
+ n_cache=n_cache,
323
+ double_reference=double_reference,
324
+ only_default=only_default,
325
+ )
326
+
327
+ return inner
328
+
329
+ # FIXME: pull out this block into a function to test that reference+implementations is made correctly
330
+ # + make sure that all arguments are passed through correctly
331
+ reference_implementation = lru_cache(maxsize=n_cache)(
332
+ self.reference.implementation
333
+ )
334
+
335
+ # use the underlying functions instead of the implementation objects so pytest can generate helpful names
336
+ if only_default:
337
+ implementations = [self.callable]
338
+ if double_reference and self.callable is not self.reference.implementation:
339
+ implementations[:0] = [reference_implementation]
340
+ else:
341
+ implementations = [i.implementation for i in self.implementations[1:]]
342
+ if double_reference:
343
+ implementations[:0] = [reference_implementation]
344
+
345
+ @pytest.mark.parametrize("reference", [reference_implementation])
346
+ @pytest.mark.parametrize("implementation", implementations)
347
+ @wraps(test)
348
+ def inner(*args, **kwargs):
349
+ return test(*args, **kwargs)
350
+
351
+ return inner
352
+
353
+
354
+ @dataclasses.dataclass(unsafe_hash=True)
355
+ class Implementation[**P, R]:
356
+ alternatives: Alternatives[P, R]
357
+ implementation: Callable[P, R]
358
+
359
+ # TODO: add something like "label" which can be printed, e.g. "examples/test_measure.py:36" which would be a hyperlink in PyCharm
360
+
361
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
362
+ self.__call__ = self.implementation
363
+ return self.__call__(*args, **kwargs)
364
+
365
+ @overload
366
+ def add(
367
+ self, implementation: _UNDEFINED = UNDEFINED, *, default: bool = False
368
+ ) -> ImplementationWrapper[P, R]: ...
369
+ @overload
370
+ def add(
371
+ self, implementation: ImplementationSig[P, R], *, default: bool = False
372
+ ) -> Implementation[P, R]: ...
373
+
374
+ def add(
375
+ self, implementation=UNDEFINED, *, default=False
376
+ ) -> Implementation[P, R] | ImplementationWrapper[P, R]:
377
+ """Add an alternative implementation."""
378
+ return self.alternatives.add(implementation, default=default)
379
+
380
+
381
+ @overload
382
+ def reference[**P, R](
383
+ implementation: _UNDEFINED = UNDEFINED, *, default: bool = False
384
+ ) -> AlternativesWrapper[P, R]: ...
385
+
386
+
387
+ @overload
388
+ def reference[**P, R](
389
+ implementation: ImplementationSig[P, R], *, default: bool = False
390
+ ) -> Alternatives[P, R]: ...
391
+
392
+
393
+ def reference[**P, R](
394
+ implementation=UNDEFINED, *, default=False
395
+ ) -> Alternatives[P, R] | AlternativesWrapper[P, R]:
396
+ if isinstance(implementation, _UNDEFINED):
397
+
398
+ def inner(f: Callable[P, R]) -> Alternatives[P, R]:
399
+ """Add the reference implementation to the alternatives"""
400
+ return Alternatives(f, default=default)
401
+
402
+ return cast(AlternativesWrapper[P, R], inner)
403
+ else:
404
+ return Alternatives(implementation, default=default)