wrapture 1.0.0.dev1__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.
wrapture/timeline.py ADDED
@@ -0,0 +1,364 @@
1
+ """The recording scope: the tape events land on, and how they find it.
2
+
3
+ Observed code calls its own methods normally; nothing threads a tape
4
+ through those calls. So when a wrapper fires it answers two questions
5
+ from ambient state: am I recording, and into what; and what call am I
6
+ nested inside. Both live in context variables, set when a timeline is
7
+ entered and restored on exit.
8
+
9
+ Context variables specifically, because a module-level global would be
10
+ shared by concurrent asyncio tasks, recording one task's calls as
11
+ children of another's, and a thread-local would fail the same way for
12
+ many tasks on one thread. Each task gets its own copy of the context,
13
+ so each records its own correctly nested tree.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextvars
19
+ import threading
20
+ from collections.abc import Iterable
21
+ from typing import Any, Protocol, runtime_checkable
22
+
23
+ from wrapt import MISSING
24
+
25
+ from .capture import NONE, REFERENCE, CapturePolicy, _capture_value, _level_of
26
+ from .eventlogs import EventLog
27
+ from .events import Event
28
+
29
+
30
+ @runtime_checkable
31
+ class _Appliable(Protocol):
32
+ # Anything with the binding lifecycle: a Binding or a BindingGroup.
33
+ # Duck-typed rather than imported, because the bindings module is a
34
+ # consumer of this one and importing it here would be circular.
35
+
36
+ def apply(self) -> Any: ...
37
+
38
+ def remove(self) -> Any: ...
39
+
40
+
41
+ class Tape:
42
+ """The ordered record of events for one timeline.
43
+
44
+ The tape assigns each event its sequence number as it is recorded,
45
+ so ordering assertions have a single authoritative order even when
46
+ events arrive from concurrently running tasks.
47
+ """
48
+
49
+ # The capture levels this sink requires of bindings that follow the
50
+ # sink (design: the sink says what it needs). REFERENCE on both
51
+ # axes, because a test asserts within the scope, where references
52
+ # are accurate and cost nothing. A streaming sink would declare
53
+ # SUMMARY arguments and NONE results instead.
54
+
55
+ capture_args: CapturePolicy = REFERENCE
56
+ capture_result: CapturePolicy = REFERENCE
57
+
58
+ def __init__(self) -> None:
59
+ self._entries: list[Event] = []
60
+ self._lock = threading.Lock()
61
+ self._seq = 0
62
+
63
+ def record(self, event: Event) -> Event:
64
+ """Assign the next sequence number to the event and append it.
65
+
66
+ Returns the event, which stays live: the recorder fills in the
67
+ outcome fields when the call completes.
68
+ """
69
+
70
+ with self._lock:
71
+ self._seq += 1
72
+ event.seq = self._seq
73
+ self._entries.append(event)
74
+
75
+ return event
76
+
77
+ @property
78
+ def all(self) -> list[Event]:
79
+ """Every recorded event, in sequence order."""
80
+
81
+ with self._lock:
82
+ return list(self._entries)
83
+
84
+ def for_binding(self, bnd: Any) -> EventLog:
85
+ """A filterable view over this tape's events for one binding."""
86
+
87
+ with self._lock:
88
+ events = [event for event in self._entries if event.binding is bnd]
89
+
90
+ return EventLog(getattr(bnd, "label", repr(bnd)), events)
91
+
92
+ def roots(self) -> list[Event]:
93
+ """The top-level events: those recorded with no observed caller."""
94
+
95
+ with self._lock:
96
+ return [event for event in self._entries if event.parent is None]
97
+
98
+ def tree(self) -> str:
99
+ """The call graph as it actually ran, one event per line,
100
+ indented by nesting depth.
101
+
102
+ A completed event shows its result after `->`; one that raised
103
+ shows `!!` and the exception type; one still in progress shows
104
+ neither.
105
+ """
106
+
107
+ lines: list[str] = []
108
+
109
+ def emit(event: Event) -> None:
110
+ injected = " (injected)" if event.injected else ""
111
+
112
+ if event.exception is not None:
113
+ marker = f" !! {type(event.exception).__name__}{injected}"
114
+ elif event.result is not MISSING:
115
+ marker = f" -> {event.result!r}{injected}"
116
+ else:
117
+ marker = ""
118
+
119
+ lines.append(" " * event.depth + str(event) + marker)
120
+
121
+ for child in event.children:
122
+ emit(child)
123
+
124
+ for root in self.roots():
125
+ emit(root)
126
+
127
+ return "\n".join(lines)
128
+
129
+ def assert_order(self, *bindings: Any) -> Tape:
130
+ """Assert the bindings recorded events in the given order.
131
+
132
+ A subsequence check, not an exact match: other events may appear
133
+ before, between and after, and only the relative order of the
134
+ given bindings' events matters. Repeating a binding requires it
135
+ to have recorded that many times in order. Raises AssertionError
136
+ naming where the expectation stalled, with the actual timeline.
137
+ """
138
+
139
+ with self._lock:
140
+ entries = list(self._entries)
141
+
142
+ position = 0
143
+ for event in entries:
144
+ if position < len(bindings) and event.binding is bindings[position]:
145
+ position += 1
146
+
147
+ if position != len(bindings):
148
+ stalled = getattr(bindings[position], "label", repr(bindings[position]))
149
+ actual = "\n".join(f" {event}" for event in entries) or " (no events)"
150
+ raise AssertionError(
151
+ f"expected order not satisfied; stalled waiting for"
152
+ f" {stalled} (position {position + 1} of {len(bindings)})\n"
153
+ f" actual timeline:\n{actual}"
154
+ )
155
+
156
+ return self
157
+
158
+
159
+ # The ambient state. The tape variable doubles as the recording switch:
160
+ # None means no timeline is active and wrappers call straight through.
161
+ # The stack variable holds the events currently in progress, innermost
162
+ # last, and is the entire source of parent, depth and children.
163
+
164
+ _tape: contextvars.ContextVar[Tape | None] = contextvars.ContextVar(
165
+ "wrapture_tape", default=None
166
+ )
167
+ _stack: contextvars.ContextVar[tuple[Event, ...]] = contextvars.ContextVar(
168
+ "wrapture_stack", default=()
169
+ )
170
+
171
+ # How many timelines are active process-wide, kept so a wrapper firing
172
+ # with no ambient tape can tell "nothing is recording anywhere" from
173
+ # "a timeline is running but this thread has no context". Threads start
174
+ # with a fresh context, so their calls otherwise vanish from the tape
175
+ # silently; see the known limitations page.
176
+
177
+ _active_lock = threading.Lock()
178
+ _active_count = 0
179
+
180
+
181
+ def _timeline_started() -> None:
182
+ global _active_count
183
+
184
+ with _active_lock:
185
+ _active_count += 1
186
+
187
+
188
+ def _timeline_finished() -> None:
189
+ global _active_count
190
+
191
+ with _active_lock:
192
+ _active_count -= 1
193
+
194
+
195
+ def _timelines_active() -> bool:
196
+ return _active_count > 0
197
+
198
+
199
+ # The reentrancy guard. Set while the recording machinery itself runs, so
200
+ # an observed callable invoked from inside the recorder (rather than from
201
+ # the code under observation) does not record recursively without bound.
202
+ # Behaviour still applies on the guarded path: only recording is skipped,
203
+ # so a call the user stubbed out stays stubbed even when the recorder
204
+ # triggers it.
205
+
206
+ _in_recorder: contextvars.ContextVar[bool] = contextvars.ContextVar(
207
+ "wrapture_in_recorder", default=False
208
+ )
209
+
210
+
211
+ def _push(event: Event) -> contextvars.Token[tuple[Event, ...]]:
212
+ # Nest the event under whatever is currently in progress, then make
213
+ # it the innermost in-progress event. The returned token restores
214
+ # the previous stack in _pop, which must run in the same context.
215
+
216
+ stack = _stack.get()
217
+ parent = stack[-1] if stack else None
218
+
219
+ event.parent = parent
220
+ event.depth = len(stack)
221
+ if parent is not None:
222
+ parent.children.append(event)
223
+
224
+ return _stack.set(stack + (event,))
225
+
226
+
227
+ def _pop(token: contextvars.Token[tuple[Event, ...]]) -> None:
228
+ _stack.reset(token)
229
+
230
+
231
+ def _capture_result(event: Event, outcome: Any, policy: CapturePolicy) -> None:
232
+ # Result capture runs under the recorder guard: at SUMMARY and above
233
+ # it calls user code (repr, deepcopy), which must not record.
234
+
235
+ if _level_of(policy) <= NONE:
236
+ return
237
+
238
+ guard = _in_recorder.set(True)
239
+ try:
240
+ event.result = _capture_value(policy, None, outcome)
241
+ finally:
242
+ _in_recorder.reset(guard)
243
+
244
+
245
+ def current_event() -> Event | None:
246
+ """The in-flight event, or None when nothing is being recorded.
247
+
248
+ The behaviour pipeline runs after its event is pushed, so this is
249
+ reachable from inside a decorates() handler, where it names the
250
+ event for the very call the handler is wrapping.
251
+ """
252
+
253
+ stack = _stack.get()
254
+ return stack[-1] if stack else None
255
+
256
+
257
+ def annotate(**data: Any) -> None:
258
+ """Merge values into the in-flight event's data dict.
259
+
260
+ Annotation is targeted capture: the caller attaches what it knows a
261
+ generic policy cannot infer (a row count, a cache hit, an immutable
262
+ copy of a value that will be mutated). A silent no-op when nothing
263
+ is being recorded, so observed code can call it unconditionally.
264
+ """
265
+
266
+ event = current_event()
267
+ if event is not None:
268
+ event.data.update(data)
269
+
270
+
271
+ class Timeline:
272
+ """A recording scope, created by timeline().
273
+
274
+ Entering sets the ambient tape and applies the bindings given at
275
+ creation, rolling back if any of them fails to apply. Exiting
276
+ removes them in reverse order and restores the previous ambient
277
+ state. The same timeline can be reused sequentially; its tape keeps
278
+ accumulating across uses.
279
+ """
280
+
281
+ def __init__(self, appliables: list[_Appliable]) -> None:
282
+ self.tape = Tape()
283
+
284
+ self._appliables = appliables
285
+ self._applied: list[_Appliable] = []
286
+ self._tape_token: contextvars.Token[Tape | None] | None = None
287
+ self._stack_token: contextvars.Token[tuple[Event, ...]] | None = None
288
+
289
+ def __enter__(self) -> Tape:
290
+ if self._tape_token is not None:
291
+ raise RuntimeError("timeline is already active")
292
+
293
+ self._tape_token = _tape.set(self.tape)
294
+ self._stack_token = _stack.set(())
295
+
296
+ # Apply every binding, rolling the whole entry back if one
297
+ # fails, so a partially patched scope never survives.
298
+
299
+ try:
300
+ for appliable in self._appliables:
301
+ appliable.apply()
302
+ self._applied.append(appliable)
303
+ except Exception:
304
+ for applied in reversed(self._applied):
305
+ applied.remove()
306
+ self._applied.clear()
307
+
308
+ self._restore()
309
+ raise
310
+
311
+ _timeline_started()
312
+ return self.tape
313
+
314
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
315
+ _timeline_finished()
316
+
317
+ for applied in reversed(self._applied):
318
+ applied.remove()
319
+ self._applied.clear()
320
+
321
+ self._restore()
322
+
323
+ # Verify declared expectations only when the block itself
324
+ # succeeded: raising here over an in-flight failure would bury
325
+ # the real cause.
326
+
327
+ if exc_type is None:
328
+ for appliable in self._appliables:
329
+ verify = getattr(appliable, "_verify", None)
330
+ if verify is not None:
331
+ verify(self.tape)
332
+
333
+ def _restore(self) -> None:
334
+ assert self._tape_token is not None
335
+ assert self._stack_token is not None
336
+
337
+ _tape.reset(self._tape_token)
338
+ _stack.reset(self._stack_token)
339
+
340
+ self._tape_token = None
341
+ self._stack_token = None
342
+
343
+
344
+ def timeline(*bindings: _Appliable | Iterable[_Appliable]) -> Timeline:
345
+ """Open a recording scope.
346
+
347
+ Bindings passed here are applied on entry and removed on exit: a
348
+ binding applied outside a timeline records nothing anyway, so the
349
+ recording scope and the useful patch lifetime are the same interval.
350
+ Accepts bindings, binding groups, or iterables of either; a group is
351
+ applied as a unit, keeping its own rollback behaviour. With no
352
+ arguments the scope only records, for bindings whose lifetime is
353
+ managed elsewhere.
354
+ """
355
+
356
+ flattened: list[_Appliable] = []
357
+
358
+ for entry in bindings:
359
+ if isinstance(entry, _Appliable):
360
+ flattened.append(entry)
361
+ else:
362
+ flattened.extend(entry)
363
+
364
+ return Timeline(flattened)
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: wrapture
3
+ Version: 1.0.0.dev1
4
+ Summary: Library for attaching bindings to arbitrary Python call sites for monkey patching, testing, tracing and profiling.
5
+ Author-email: Graham Dumpleton <Graham.Dumpleton@gmail.com>
6
+ License-Expression: BSD-2-Clause
7
+ Project-URL: Homepage, https://github.com/GrahamDumpleton/wrapture
8
+ Project-URL: Documentation, https://wrapture.readthedocs.io
9
+ Project-URL: Bug Tracker, https://github.com/GrahamDumpleton/wrapture/issues/
10
+ Keywords: wrapper,monkey patching,tracing,testing,profiling
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: Python :: Implementation :: CPython
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: wrapt>=2.4.0rc4
21
+ Provides-Extra: dev
22
+ Requires-Dist: mypy; extra == "dev"
23
+ Requires-Dist: pytest; extra == "dev"
24
+ Requires-Dist: ruff; extra == "dev"
25
+ Requires-Dist: setuptools; extra == "dev"
26
+ Provides-Extra: docs
27
+ Requires-Dist: myst-parser; extra == "docs"
28
+ Requires-Dist: sphinx; extra == "docs"
29
+ Requires-Dist: sphinx-rtd-theme; extra == "docs"
30
+ Dynamic: license-file
31
+
32
+ # wrapture
33
+
34
+ **Trace assertions without instrumenting your code.**
35
+
36
+ [![Tests](https://github.com/GrahamDumpleton/wrapture/actions/workflows/build-test-release.yml/badge.svg?branch=develop)](https://github.com/GrahamDumpleton/wrapture/actions/workflows/build-test-release.yml)
37
+ [![Documentation](https://readthedocs.org/projects/wrapture/badge/?version=latest)](https://wrapture.readthedocs.io)
38
+
39
+ wrapture (`wrapt` + `capture`) is a Python library for attaching bindings to
40
+ arbitrary call sites, without modifying the code being observed, and doing
41
+ something useful with what flows through them.
42
+
43
+ It is a sibling project to [wrapt](https://github.com/GrahamDumpleton/wrapt)
44
+ and [autowrapt](https://github.com/GrahamDumpleton/autowrapt), building on the
45
+ safe monkey-patching machinery wrapt provides.
46
+
47
+ > **Status: early development.** The monkey patching and unit testing layers
48
+ > are implemented; the tracing and profiling layers are designed but not
49
+ > built. Nothing is published to PyPI yet.
50
+
51
+ ## Documentation
52
+
53
+ Full documentation is at [wrapture.readthedocs.io](https://wrapture.readthedocs.io).
54
+ Start with the [getting started](https://wrapture.readthedocs.io/en/latest/getting-started.html)
55
+ page: everything on it can be pasted into a Python interpreter. Coming
56
+ from `unittest.mock`? There is a
57
+ [comparison page](https://wrapture.readthedocs.io/en/latest/coming-from-mock.html)
58
+ mapping each mock idiom to its wrapture counterpart.
59
+
60
+ ## Thirty seconds of it
61
+
62
+ None of the classes below import wrapture or know they are observed:
63
+
64
+ ```python
65
+ place = wrapture.binding(OrderService, "place")
66
+ charge = wrapture.binding(Gateway, "charge")
67
+ record = wrapture.binding(Ledger, "record")
68
+
69
+ with wrapture.timeline(place, charge, record) as tape:
70
+ OrderService().place(500)
71
+
72
+ print(tape.tree())
73
+ ```
74
+
75
+ ```
76
+ OrderService.place(amount=500) -> {'id': 'ch_500', 'amount': 500}
77
+ Gateway.charge(amount=500, currency='USD') -> {'id': 'ch_500', 'amount': 500}
78
+ Ledger.record(entry={'id': 'ch_500', 'amount': 500}) -> 'led_ch_500'
79
+ ```
80
+
81
+ The same bindings intervene as well as observe: stub a result, inject a
82
+ failure, or transform one argument while the real code keeps running.
83
+
84
+ ## What it does
85
+
86
+ One mechanism, four uses, in increasing order of machinery:
87
+
88
+ 1. **Monkey patching.** A clean lifecycle and behaviour vocabulary over
89
+ wrapt's `wrap_object()`. Point at a method by name and stub it, fail it,
90
+ transform its arguments or result, or wrap it with a decorator, then
91
+ remove it again, with honest reporting if something else displaced the
92
+ patch in the meantime. Useful entirely on its own, with nothing else
93
+ switched on.
94
+
95
+ 2. **Unit testing.** Observe and assert on how calls actually flowed through a
96
+ *real* call graph (nesting, ordering, arguments and return values) and
97
+ optionally intervene (stub, transform, fail-inject). Unlike a `Mock`,
98
+ which fabricates values and cannot see calls an object makes to itself,
99
+ wrapture watches the real code run. This makes it possible to test code
100
+ with no injectable seams at all, and to assert on what *didn't* happen on
101
+ an error path: inject a gateway timeout, then verify the ledger was not
102
+ written, the receipt was not sent, and the compensating refund was issued.
103
+
104
+ 3. **Ad-hoc tracing.** Attach bindings to a running application, including
105
+ one you cannot modify or redeploy, and emit a structured, nested trace to
106
+ process or chart elsewhere. Name a handful of methods and a call tree
107
+ appears; no code changes required.
108
+
109
+ 4. **Targeted profiling.** Use a binding as a *scope* within which CPython's
110
+ own profiling machinery is active, so you can profile one subsystem of a
111
+ live process instead of everything.
112
+
113
+ The distinction that matters: most tracing and profiling tools either need
114
+ the code to have been written with them in mind, or can only be switched on
115
+ for the whole program at once. wrapture needs neither: you point at a method
116
+ by name and a trace appears.
117
+
118
+ ## Why
119
+
120
+ No single existing tool covers "point at arbitrary methods, get a structured
121
+ nested trace, assert on it or export it, in tests or in production":
122
+
123
+ - `unittest.mock` records a flat call list, with no nesting and no return
124
+ values, and a patched call returns a fabricated `MagicMock` rather than
125
+ running the real code.
126
+ - Span-assertion tools (`logfire.testing`, OpenTelemetry's
127
+ `InMemorySpanExporter`) require the code to already be instrumented.
128
+ - `sys.settrace` tools (`hunter`, `snoop`) give a firehose with no assertion
129
+ API.
130
+ - `cProfile` cannot scope to a subsystem in a live process, and APM agents
131
+ are all-or-nothing products rather than a toolkit.
132
+
133
+ wrapture fills that gap: a targeted call tree with normalized arguments and
134
+ return values, produced by naming the methods you care about, usable as a
135
+ testing assertion library, a tracing tool, or both at once.
136
+
137
+ ## What it is not
138
+
139
+ - **Not a replacement for `unittest.mock`.** It complements mocking where
140
+ code has seams; it exists for the code that doesn't.
141
+ - **Not a sampling profiler.** `py-spy` and `austin` do that better and
142
+ without distortion.
143
+ - **Not a production APM.** It is a toolkit that APM-like things could be
144
+ built on.
145
+ - **Not an OpenTelemetry competitor.** It should emit to OTel, not replace
146
+ it.
147
+
148
+ ## Requirements
149
+
150
+ - Python 3.12+
151
+ - [wrapt](https://github.com/GrahamDumpleton/wrapt) 2.4.0+
152
+
153
+ ## License
154
+
155
+ BSD 2-Clause. See [LICENSE](LICENSE).
@@ -0,0 +1,18 @@
1
+ wrapture/__init__.py,sha256=wFXWftQjghULEMHW6UP5-ucFxl19fTdc1VQSxhUJ2y8,2168
2
+ wrapture/attributes.py,sha256=kcdKvsyXxPTlr8aiJRSuXlEyTxAoHBLS55iw2X731To,11622
3
+ wrapture/behaviours.py,sha256=I3iNUquVfcWVXKWvis9o7PUq1Zh-XCQdQoRG8AgsOjE,12894
4
+ wrapture/bindings.py,sha256=dk-W_Nl_b8m6O55L3LgS-jFyJ_LBNzb6edl3fMrcrpw,36595
5
+ wrapture/capture.py,sha256=JIgefS3ASrGos8s798Yr6FTss7rh6n01jPAMGm6iUWY,5689
6
+ wrapture/eventlogs.py,sha256=eTMtc5NizGPTwcsHfw4FrdzHXW2rHHMutJ4M7WXsFSU,7917
7
+ wrapture/events.py,sha256=borkvI-SdGGvhgOT9acRg2XkbnNga0Ikf5imfjkxFyY,6902
8
+ wrapture/exceptions.py,sha256=1WB2qLj3E-aXVOutipr9pMDWBm4_Qw0S8tkCABRaD5c,1415
9
+ wrapture/iterators.py,sha256=X5_WAJY9LsuTC1Bo5y2QYael8ZFZvLI8bgo5jJkTkaA,14021
10
+ wrapture/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ wrapture/pytest_plugin.py,sha256=yMDZHgFofNeGQP4USIPtjK4mKWxwVk5E0SSZ9pKIeTI,3859
12
+ wrapture/stacks.py,sha256=CcnCD4bTQbl-JDLms1Nx5FTJmjlkEJbmfGTVgO_Dxi0,2644
13
+ wrapture/timeline.py,sha256=DlCFhKx0qER1upqC5yvI0Y-7oBwHh3jQQ-qFp_bTZxs,12044
14
+ wrapture-1.0.0.dev1.dist-info/licenses/LICENSE,sha256=RPMyRnXwgrw9co5o6wY9G3DKlb-oBZ44tpiRnuYeec8,1299
15
+ wrapture-1.0.0.dev1.dist-info/METADATA,sha256=H_gsOD_sGyvAkr1QbrHke80vn1jBB8gv0m4TFdJ-U0Q,6699
16
+ wrapture-1.0.0.dev1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
17
+ wrapture-1.0.0.dev1.dist-info/top_level.txt,sha256=eUnd7dTkQgJ1f9rnewuv3zt-yCP-Qc36DMmRskb8AIU,9
18
+ wrapture-1.0.0.dev1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,24 @@
1
+ Copyright (c) 2026, Graham Dumpleton
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ * Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ * Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
17
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
18
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
19
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
20
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
21
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
22
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
23
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
24
+ POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ wrapture