cot-assert 0.1.0__py2.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.
cot_assert/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Assertion rewriting with a structured AssertionError."""
2
+
3
+ from __future__ import absolute_import, division, print_function
4
+
5
+ from ._entry import report_annotated
6
+ from ._error import AnnotatedAssertion, AssertSite, annotated
7
+ from ._hook import install, uninstall
8
+ from ._rewrite import rewrite_asserts, rewrite_source
9
+
10
+ # the public name, in tracebacks and pickles
11
+ AnnotatedAssertion.__module__ = __name__
12
+
13
+ __all__ = [
14
+ "AnnotatedAssertion",
15
+ "AssertSite",
16
+ "annotated",
17
+ "install",
18
+ "report_annotated",
19
+ "rewrite_asserts",
20
+ "rewrite_source",
21
+ "uninstall",
22
+ ]
cot_assert/_entry.py ADDED
@@ -0,0 +1,29 @@
1
+ """Making AnnotatedAssertion explanations visible from translated programs."""
2
+
3
+ from __future__ import absolute_import, division, print_function
4
+
5
+ import os
6
+
7
+ from ._error import AnnotatedAssertion
8
+ from ._rpy import we_are_translated
9
+
10
+
11
+ def report_annotated(func):
12
+ """Wrap a translated entry point to print an escaping AnnotatedAssertion.
13
+
14
+ Uncaught, RPython aborts with just the class name. The wrapper writes
15
+ the explanation to stderr first and re-raises, so the abort still
16
+ happens. On the host the traceback already shows it; nothing is written.
17
+ """
18
+
19
+ def entry_point(*args):
20
+ try:
21
+ return func(*args)
22
+ # RPython rejects ``except`` on AssertionError or any subclass
23
+ except Exception as exc:
24
+ if we_are_translated() and isinstance(exc, AnnotatedAssertion):
25
+ os.write(2, exc.render_plain() + "\n")
26
+ raise
27
+
28
+ entry_point.__name__ = "report_annotated_" + func.__name__
29
+ return entry_point
cot_assert/_error.py ADDED
@@ -0,0 +1,155 @@
1
+ """The assertion error raised by rewritten asserts.
2
+
3
+ Everything in this module that is not marked host-only is in the RPython
4
+ subset: fields keep one type per attribute, and host-only paths sit behind
5
+ ``we_are_translated()``, which the flow space folds to True.
6
+ """
7
+
8
+ from __future__ import absolute_import, division, print_function
9
+
10
+ from ._rpy import specialize, we_are_translated
11
+ from ._values import value
12
+
13
+
14
+ class AssertSite(object):
15
+ """Static description of one assert statement, prebuilt by the rewriter."""
16
+
17
+ def __init__(self, source, path_labels, shape=None, paths=None):
18
+ self.source = source
19
+ # One label list per failure path: a path that skipped a short-circuit
20
+ # operand has no value for it. Lists, not tuples: prebuilt tuples of
21
+ # different lengths do not unify in RPython, lists of str do.
22
+ self.path_labels = path_labels
23
+ # Host only, never read by translated code, so never annotated:
24
+ # the expression tree and per-path records the renderer walks.
25
+ # See _rewrite.AssertRewriter.
26
+ self.shape = shape
27
+ self.paths = paths
28
+
29
+
30
+ class AnnotatedAssertion(AssertionError):
31
+ """An AssertionError that carries the values its explanation is built from.
32
+
33
+ Translated, ``values`` holds str; on the host it holds the objects
34
+ themselves until ``finalize`` renders them and drops them.
35
+ """
36
+
37
+ # Unpatched RPython takes every AssertionError subclass for a built-in
38
+ # exception: it skips __init__ and allows no attributes but the ones
39
+ # listed here. Code that has to translate there builds instances with
40
+ # ``annotated()``; see docs/design/rpython.md.
41
+ _attrs_ = ["msg", "site", "path", "values", "labels", "rendered"]
42
+
43
+ def __init__(self, msg=None, site=None, path=0, values=None):
44
+ if not we_are_translated():
45
+ AssertionError.__init__(self)
46
+ self.init_fields(msg, site, path, values)
47
+
48
+ def init_fields(self, msg, site, path, values):
49
+ self.msg = msg
50
+ self.site = site
51
+ self.path = path
52
+ if values is None:
53
+ values = []
54
+ self.values = values
55
+ self.labels = []
56
+ self.rendered = None
57
+
58
+ @specialize.argtype(2)
59
+ def annotate(self, label, obj):
60
+ """Attach a labelled value; the label follows the site's labels."""
61
+ self.labels.append(label)
62
+ self.values.append(value(obj))
63
+ return self
64
+
65
+ def all_labels(self):
66
+ if self.site is None:
67
+ return self.labels
68
+ return self.site.path_labels[self.path] + self.labels
69
+
70
+ def render_plain(self):
71
+ """The explanation from str values, as translated code can build it."""
72
+ lines = []
73
+ if self.msg is not None:
74
+ lines.append(self.msg)
75
+ if self.site is not None:
76
+ lines.append("assert " + self.site.source)
77
+ labels = self.all_labels()
78
+ i = 0
79
+ while i < len(labels) and i < len(self.values):
80
+ lines.append(" where " + labels[i] + " = " + self.values[i])
81
+ i += 1
82
+ return "\n".join(lines)
83
+
84
+ def render(self):
85
+ """Host only: the explanation text."""
86
+ if self.rendered is not None:
87
+ return self.rendered
88
+ from ._render import render
89
+
90
+ return render(self)
91
+
92
+ def finalize(self, mode="notes"):
93
+ """Host only: render once and drop the tracked values.
94
+
95
+ ``mode="notes"`` adds the explanation as a PEP 678 note,
96
+ ``mode="message"`` puts it into ``args`` for hosts that do not show
97
+ notes (pytest 4.6, Python < 3.11 tracebacks).
98
+ """
99
+ if mode not in ("notes", "message"):
100
+ raise ValueError("unknown finalize mode: %r" % (mode,))
101
+ if self.values is None:
102
+ return self
103
+ text = self.render()
104
+ # keep only text: nothing that holds on to the tested objects or
105
+ # needs this package's classes to unpickle into a meaningful state
106
+ self.rendered = text
107
+ self.values = None
108
+ self.site = None
109
+ if self.msg is not None:
110
+ self.msg = str(self.msg)
111
+ if mode == "notes":
112
+ _add_note(self, text)
113
+ else:
114
+ self.args = (text,)
115
+ return self
116
+
117
+ def __reduce__(self):
118
+ # BaseException's reduce passes args to __init__, which reads them
119
+ # as msg/site/...; restore args and state directly instead
120
+ return _unpickle, (type(self), self.args, self.__dict__)
121
+
122
+ def __str__(self):
123
+ if we_are_translated():
124
+ return self.render_plain()
125
+ if self.args:
126
+ return str(self.args[0])
127
+ if self.values is None:
128
+ # finalized into notes: the note carries the explanation
129
+ return self.msg if self.msg is not None else ""
130
+ return self.render()
131
+
132
+
133
+ def annotated(msg=None, site=None, path=0, values=None):
134
+ """Build an AnnotatedAssertion; the constructor for RPython code."""
135
+ exc = AnnotatedAssertion()
136
+ exc.init_fields(msg, site, path, values)
137
+ return exc
138
+
139
+
140
+ def _unpickle(cls, args, state):
141
+ exc = cls.__new__(cls)
142
+ exc.args = args
143
+ exc.__dict__.update(state)
144
+ return exc
145
+
146
+
147
+ def _add_note(exc, text):
148
+ add_note = getattr(exc, "add_note", None)
149
+ if add_note is not None:
150
+ add_note(text)
151
+ else:
152
+ notes = getattr(exc, "__notes__", None)
153
+ if notes is None:
154
+ notes = exc.__notes__ = []
155
+ notes.append(text)
cot_assert/_hook.py ADDED
@@ -0,0 +1,224 @@
1
+ """Import hook that rewrites the asserts of selected modules."""
2
+
3
+ from __future__ import absolute_import, division, print_function
4
+
5
+ import fnmatch
6
+ import hashlib
7
+ import os
8
+ import sys
9
+
10
+ from ._rewrite import rewrite_source
11
+
12
+ PY2 = sys.version_info[0] == 2
13
+
14
+
15
+ def _rewriter_digest():
16
+ """Changes whenever the code that shapes rewritten modules changes.
17
+
18
+ Cached bytecode is only valid for the rewriter that produced it; hashing
19
+ the sources avoids having to remember a version bump.
20
+ """
21
+ here = os.path.dirname(os.path.abspath(__file__))
22
+ digest = hashlib.sha1()
23
+ for name in ("_rewrite.py", "_unparse.py"):
24
+ try:
25
+ with open(os.path.join(here, name), "rb") as f:
26
+ digest.update(f.read())
27
+ except (IOError, OSError):
28
+ return "unknown"
29
+ return digest.hexdigest()[:10]
30
+
31
+
32
+ CACHE_TAG = "cot_assert-" + _rewriter_digest()
33
+
34
+
35
+ class _Matcher(object):
36
+ def __init__(self, patterns):
37
+ self.patterns = list(patterns)
38
+
39
+ def __call__(self, fullname):
40
+ if fullname == "cot_assert" or fullname.startswith("cot_assert."):
41
+ return False
42
+ for pattern in self.patterns:
43
+ if fnmatch.fnmatchcase(fullname, pattern):
44
+ return True
45
+ # a package name selects its submodules too
46
+ if fullname.startswith(pattern + "."):
47
+ return True
48
+ # a wildcard pattern like "test_*" matches the last dotted component
49
+ last = fullname.rpartition(".")[2]
50
+ if _is_glob(pattern) and "." not in pattern:
51
+ if fnmatch.fnmatchcase(last, pattern):
52
+ return True
53
+ return False
54
+
55
+
56
+ def _is_glob(pattern):
57
+ return any(char in pattern for char in "*?[")
58
+
59
+
60
+ def install(match):
61
+ """Rewrite asserts in modules imported from now on whose name matches.
62
+
63
+ ``match`` is a list of module names (selecting their submodules too) or
64
+ fnmatch patterns; a wildcard pattern without dots also matches the last
65
+ component of a dotted name. Returns the hook; ``uninstall(hook)`` removes it.
66
+ """
67
+ hook = RewriteHook(match)
68
+ sys.meta_path.insert(0, hook)
69
+ return hook
70
+
71
+
72
+ def uninstall(hook):
73
+ try:
74
+ sys.meta_path.remove(hook)
75
+ except ValueError:
76
+ pass
77
+
78
+
79
+ if not PY2:
80
+ import importlib.machinery
81
+ import importlib.util
82
+ import marshal
83
+
84
+ class RewriteHook(object):
85
+ def __init__(self, match):
86
+ self.matches = _Matcher(match)
87
+
88
+ def find_spec(self, fullname, path=None, target=None):
89
+ if not self.matches(fullname):
90
+ return None
91
+ spec = importlib.machinery.PathFinder.find_spec(fullname, path)
92
+ if spec is None or not isinstance(
93
+ spec.loader, importlib.machinery.SourceFileLoader
94
+ ):
95
+ return None
96
+ spec.loader = RewritingLoader(fullname, spec.origin)
97
+ return spec
98
+
99
+ def invalidate_caches(self):
100
+ pass
101
+
102
+ class RewritingLoader(importlib.machinery.SourceFileLoader):
103
+ """A source loader whose code has its asserts rewritten.
104
+
105
+ It keeps its own bytecode cache next to the regular one; the regular
106
+ ``__pycache__`` entry holds unrewritten code and must not be used.
107
+ """
108
+
109
+ def get_code(self, fullname):
110
+ path = self.get_filename(fullname)
111
+ st = os.stat(path)
112
+ cache = cache_path(path)
113
+ code = _read_cache(cache, st)
114
+ if code is None:
115
+ code = rewrite_source(self.get_data(path), path)
116
+ if not sys.dont_write_bytecode:
117
+ _write_cache(cache, st, code)
118
+ return code
119
+
120
+ def cache_path(source_path):
121
+ directory, filename = os.path.split(source_path)
122
+ stem = filename.rpartition(".")[0]
123
+ tag = sys.implementation.cache_tag
124
+ return os.path.join(
125
+ directory, "__pycache__", "%s.%s-%s.pyc" % (stem, tag, CACHE_TAG)
126
+ )
127
+
128
+ def _header(st):
129
+ return (
130
+ importlib.util.MAGIC_NUMBER
131
+ + (0).to_bytes(4, "little")
132
+ + (int(st.st_mtime) & 0xFFFFFFFF).to_bytes(4, "little")
133
+ + (st.st_size & 0xFFFFFFFF).to_bytes(4, "little")
134
+ )
135
+
136
+ def _read_cache(cache, st):
137
+ try:
138
+ with open(cache, "rb") as f:
139
+ data = f.read()
140
+ except OSError:
141
+ return None
142
+ header = _header(st)
143
+ if data[: len(header)] != header:
144
+ return None
145
+ try:
146
+ return marshal.loads(data[len(header) :])
147
+ except (EOFError, ValueError, TypeError):
148
+ return None
149
+
150
+ def _write_cache(cache, st, code):
151
+ data = _header(st) + marshal.dumps(code)
152
+ tmp = "%s.%d" % (cache, os.getpid())
153
+ try:
154
+ os.makedirs(os.path.dirname(cache), exist_ok=True)
155
+ with open(tmp, "wb") as f:
156
+ f.write(data)
157
+ os.replace(tmp, cache)
158
+ except OSError:
159
+ # read-only trees just go without a cache
160
+ try:
161
+ os.unlink(tmp)
162
+ except OSError:
163
+ pass
164
+
165
+ else:
166
+ import imp
167
+
168
+ class RewriteHook(object):
169
+ """PEP 302 finder and loader; Python 2 goes without a disk cache."""
170
+
171
+ def __init__(self, match):
172
+ self.matches = _Matcher(match)
173
+ self._found = {}
174
+ self._sources = {}
175
+
176
+ def find_module(self, fullname, path=None):
177
+ if not self.matches(fullname):
178
+ return None
179
+ name = fullname.rpartition(".")[2]
180
+ try:
181
+ fd, pathname, (_, _, kind) = imp.find_module(name, path)
182
+ except ImportError:
183
+ return None
184
+ if fd is not None:
185
+ fd.close()
186
+ is_package = kind == imp.PKG_DIRECTORY
187
+ if is_package:
188
+ pathname = os.path.join(pathname, "__init__.py")
189
+ if not os.path.isfile(pathname):
190
+ return None
191
+ elif kind != imp.PY_SOURCE:
192
+ return None
193
+ self._found[fullname] = (pathname, is_package)
194
+ return self
195
+
196
+ def load_module(self, fullname):
197
+ if fullname in sys.modules:
198
+ return sys.modules[fullname]
199
+ pathname, is_package = self._found.pop(fullname)
200
+ with open(pathname, "rb") as f:
201
+ code = rewrite_source(f.read(), pathname)
202
+ module = imp.new_module(fullname)
203
+ module.__file__ = pathname
204
+ module.__loader__ = self
205
+ if is_package:
206
+ module.__path__ = [os.path.dirname(pathname)]
207
+ module.__package__ = fullname
208
+ else:
209
+ module.__package__ = fullname.rpartition(".")[0]
210
+ self._sources[fullname] = pathname
211
+ sys.modules[fullname] = module
212
+ try:
213
+ exec(code, module.__dict__)
214
+ except BaseException:
215
+ sys.modules.pop(fullname, None)
216
+ raise
217
+ return sys.modules[fullname]
218
+
219
+ def is_package(self, fullname):
220
+ return os.path.basename(self._sources[fullname]) == "__init__.py"
221
+
222
+ def get_source(self, fullname):
223
+ with open(self._sources[fullname], "rb") as f:
224
+ return f.read()
cot_assert/_llexc.py ADDED
@@ -0,0 +1,75 @@
1
+ """Host view of an AnnotatedAssertion raised under RPython's llinterpreter."""
2
+
3
+ from __future__ import absolute_import, division, print_function
4
+
5
+ from ._error import AnnotatedAssertion, AssertSite
6
+
7
+
8
+ def from_llexception(interp, exc):
9
+ """The AnnotatedAssertion inside an ``LLException``, or None.
10
+
11
+ ``interp`` is the ``LLInterpreter`` that raised it; its rtyper knows the
12
+ low-level layout of the class.
13
+ """
14
+ from rpython.rtyper.annlowlevel import hlstr
15
+ from rpython.rtyper.lltypesystem import lltype
16
+ from rpython.rtyper.rclass import getinstancerepr
17
+
18
+ klass, inst = exc.args[0], exc.args[1]
19
+ rtyper = interp.typer
20
+ bookkeeper = rtyper.annotator.bookkeeper
21
+ try:
22
+ classdef = bookkeeper.getuniqueclassdef(AnnotatedAssertion)
23
+ except Exception:
24
+ return None
25
+ r_inst = getinstancerepr(rtyper, classdef)
26
+ vtable = r_inst.rclass.getvtable()
27
+ if not _is_subclass(klass, vtable):
28
+ return None
29
+ inst = lltype.cast_pointer(r_inst.lowleveltype, inst)
30
+
31
+ def text(ll):
32
+ # a field that only ever holds one constant is Void, and reads back
33
+ # as that host constant
34
+ if ll is None or isinstance(ll, str):
35
+ return ll
36
+ return hlstr(ll) if ll else None
37
+
38
+ def strlist(ll):
39
+ if not ll:
40
+ return []
41
+ return [text(ll.ll_getitem_fast(i)) for i in range(ll.ll_length())]
42
+
43
+ result = AnnotatedAssertion(
44
+ text(inst.inst_msg),
45
+ _host_site(rtyper, inst.inst_site),
46
+ inst.inst_path,
47
+ strlist(inst.inst_values),
48
+ )
49
+ result.labels = strlist(inst.inst_labels)
50
+ return result
51
+
52
+
53
+ def _is_subclass(klass, vtable):
54
+ # RPython vtables carry a [min, max] id range for subclass checks
55
+ low, high = vtable.subclassrange_min, vtable.subclassrange_max
56
+ return low <= klass.subclassrange_min < high
57
+
58
+
59
+ def _host_site(rtyper, ll_site):
60
+ """The prebuilt host AssertSite behind a low-level one.
61
+
62
+ Sites are prebuilt constants, so the host object exists; its fields that
63
+ translated code never reads are not in the low-level struct at all.
64
+ """
65
+ from rpython.rtyper.lltypesystem import lltype
66
+ from rpython.rtyper.rclass import getinstancerepr
67
+
68
+ if not ll_site:
69
+ return None
70
+ classdef = rtyper.annotator.bookkeeper.getuniqueclassdef(AssertSite)
71
+ r_site = getinstancerepr(rtyper, classdef)
72
+ for host, ll in r_site.iprebuiltinstances.items():
73
+ if lltype.cast_pointer(lltype.typeOf(ll_site), ll) == ll_site:
74
+ return host
75
+ raise LookupError("no prebuilt AssertSite for %r" % (ll_site,))