wherewent 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.
wherewent/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """wherewent — a zero-config SQL flight recorder for SQLAlchemy 2.x batch jobs."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,2 @@
1
+ """Shim package. Its directory is placed on the child's PYTHONPATH so Python
2
+ auto-imports the adjacent sitecustomize.py at interpreter startup."""
@@ -0,0 +1,18 @@
1
+ """Auto-imported by Python at startup (this dir is on the child's PYTHONPATH).
2
+
3
+ Only acts when the CLI marked the environment. Any failure is swallowed so the
4
+ job runs exactly as it would without wherewent.
5
+
6
+ TODO: chaining the site's original sitecustomize is out of scope for this
7
+ prototype (if the environment already shipped one, it is shadowed here).
8
+ """
9
+
10
+ import os
11
+
12
+ if os.environ.get("WHEREWENT_ACTIVE") == "1":
13
+ try:
14
+ from wherewent.recorder import install_from_env
15
+
16
+ install_from_env()
17
+ except Exception:
18
+ pass
wherewent/callsite.py ADDED
@@ -0,0 +1,99 @@
1
+ """Call-site resolution — find the user-code frame that issued a query.
2
+
3
+ This runs on the hot path (once per execution), so is_library_file is memoized
4
+ and the full stack capture is reserved for the first few samples of each group.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import sysconfig
10
+
11
+ # memoization cache for is_library_file (keyed by raw filename string)
12
+ _LIB_CACHE: "dict[str, bool]" = {}
13
+
14
+ # directory of the installed wherewent package itself (its frames are "library")
15
+ _WHEREWENT_DIR = os.path.dirname(os.path.abspath(__file__))
16
+
17
+ # stdlib / install-tree prefixes computed once
18
+ _STDLIB_PREFIXES = set()
19
+ for _key in ("stdlib", "platstdlib", "purelib", "platlib"):
20
+ try:
21
+ _p = sysconfig.get_paths().get(_key)
22
+ if _p:
23
+ _STDLIB_PREFIXES.add(os.path.abspath(_p))
24
+ except Exception:
25
+ pass
26
+ for _p in (getattr(sys, "prefix", None), getattr(sys, "base_prefix", None)):
27
+ if _p:
28
+ _STDLIB_PREFIXES.add(os.path.abspath(_p))
29
+ _STDLIB_PREFIXES = tuple(_STDLIB_PREFIXES)
30
+
31
+
32
+ def _compute_is_library(filename: str) -> bool:
33
+ # frozen / built-in / <string> / <stdin> pseudo-files are never user code
34
+ if not filename or filename.startswith("<"):
35
+ return True
36
+ path = os.path.abspath(filename)
37
+ sep = os.sep
38
+ if "site-packages" in path or "dist-packages" in path:
39
+ return True
40
+ if sep + "sqlalchemy" + sep in path:
41
+ return True
42
+ if path.startswith(_WHEREWENT_DIR):
43
+ return True
44
+ for prefix in _STDLIB_PREFIXES:
45
+ if path.startswith(prefix):
46
+ return True
47
+ return False
48
+
49
+
50
+ def is_library_file(filename: str) -> bool:
51
+ """True if *filename* belongs to a library/stdlib/wherewent (memoized)."""
52
+ cached = _LIB_CACHE.get(filename)
53
+ if cached is not None:
54
+ return cached
55
+ result = _compute_is_library(filename)
56
+ _LIB_CACHE[filename] = result
57
+ return result
58
+
59
+
60
+ def _short(filename: str) -> str:
61
+ """A compact display name: path relative to cwd, else basename."""
62
+ try:
63
+ rel = os.path.relpath(filename, os.getcwd())
64
+ if not rel.startswith(".."):
65
+ return rel
66
+ except Exception:
67
+ pass
68
+ return os.path.basename(filename)
69
+
70
+
71
+ def resolve_call_site(skip: int = 1) -> "tuple[str, int, str] | None":
72
+ """Walk up the stack and return the first user-code frame.
73
+
74
+ Returns (short_file, lineno, function) or None if only library frames exist.
75
+ """
76
+ try:
77
+ frame = sys._getframe(skip)
78
+ except ValueError:
79
+ return None
80
+ while frame is not None:
81
+ filename = frame.f_code.co_filename
82
+ if not is_library_file(filename):
83
+ return (_short(filename), frame.f_lineno, frame.f_code.co_name)
84
+ frame = frame.f_back
85
+ return None
86
+
87
+
88
+ def capture_stack(limit: int = 30) -> "list[str]":
89
+ """Return up to *limit* frames (user + library) as 'file:line in func'."""
90
+ frames = []
91
+ try:
92
+ frame = sys._getframe(1)
93
+ except ValueError:
94
+ return frames
95
+ while frame is not None and len(frames) < limit:
96
+ code = frame.f_code
97
+ frames.append(f"{_short(code.co_filename)}:{frame.f_lineno} in {code.co_name}")
98
+ frame = frame.f_back
99
+ return frames
wherewent/cli.py ADDED
@@ -0,0 +1,90 @@
1
+ """Console entry point: `wherewent run [--save PATH] <command...>`.
2
+
3
+ Launches the target command in a subprocess whose PYTHONPATH is arranged so
4
+ Python auto-imports our sitecustomize shim, which installs the recorder before
5
+ the job's own code runs. Zero changes to the job itself.
6
+ """
7
+
8
+ import os
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+
13
+ USAGE = (
14
+ "usage: wherewent run [--save PATH] <command...>\n"
15
+ " e.g. wherewent run python job.py\n"
16
+ " wherewent run --save out.json python -m mypkg\n"
17
+ " wherewent run job.py (bare script uses this interpreter)\n"
18
+ )
19
+
20
+
21
+ def _usage():
22
+ sys.stderr.write(USAGE)
23
+
24
+
25
+ def _child_env(save):
26
+ import wherewent
27
+ import wherewent._shim as shim
28
+
29
+ # Directory that literally contains sitecustomize.py (so Python auto-imports it).
30
+ shim_dir = os.path.dirname(os.path.abspath(shim.__file__))
31
+ # Directory that contains the `wherewent` package, so the child can import it.
32
+ pkg_parent = os.path.dirname(os.path.dirname(os.path.abspath(wherewent.__file__)))
33
+
34
+ env = os.environ.copy()
35
+ existing = env.get("PYTHONPATH", "")
36
+ parts = [shim_dir, pkg_parent]
37
+ if existing:
38
+ parts.append(existing)
39
+ env["PYTHONPATH"] = os.pathsep.join(parts)
40
+ env["WHEREWENT_ACTIVE"] = "1"
41
+ env["WHEREWENT_SAVE"] = save or ""
42
+ return env
43
+
44
+
45
+ def main(argv=None):
46
+ argv = list(sys.argv[1:] if argv is None else argv)
47
+ if not argv or argv[0] != "run":
48
+ _usage()
49
+ return 2
50
+
51
+ args = argv[1:]
52
+ save = None
53
+ i = 0
54
+ while i < len(args):
55
+ a = args[i]
56
+ if a == "--save":
57
+ if i + 1 >= len(args):
58
+ _usage()
59
+ return 2
60
+ save = args[i + 1]
61
+ i += 2
62
+ continue
63
+ break # first non-option token starts the command
64
+ command = args[i:]
65
+
66
+ if not command:
67
+ _usage()
68
+ return 2
69
+
70
+ # A bare `script.py` runs under this interpreter.
71
+ if command[0].endswith(".py"):
72
+ command = [sys.executable] + command
73
+
74
+ env = _child_env(save)
75
+
76
+ # While the child runs, the parent ignores Ctrl-C so the SIGINT goes to the
77
+ # child (which finalizes and prints the report); we exit with its code.
78
+ prev = signal.signal(signal.SIGINT, signal.SIG_IGN)
79
+ try:
80
+ proc = subprocess.run(command, env=env)
81
+ except FileNotFoundError:
82
+ sys.stderr.write(f"wherewent: command not found: {command[0]}\n")
83
+ return 127
84
+ finally:
85
+ signal.signal(signal.SIGINT, prev)
86
+ return proc.returncode
87
+
88
+
89
+ if __name__ == "__main__":
90
+ sys.exit(main())
wherewent/normalize.py ADDED
@@ -0,0 +1,55 @@
1
+ """SQL normalization — turn a raw statement into a literal-free group signature.
2
+
3
+ We NEVER keep parameter values. The output is a shape string used only to group
4
+ structurally-identical queries together.
5
+ """
6
+
7
+ import hashlib
8
+ import re
9
+
10
+ # --- pre-compiled patterns (applied in order) ---------------------------------
11
+
12
+ # 1. comments: -- to end of line, and /* ... */ blocks (dotall for multiline)
13
+ _LINE_COMMENT = re.compile(r"--[^\n]*")
14
+ _BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
15
+
16
+ # 2. single-quoted string literals, honoring the '' escape sequence
17
+ _STRING = re.compile(r"'(?:[^']|'')*'")
18
+
19
+ # 3. bind placeholders: %s, %(name)s, :name, $1-style (done before numbers so
20
+ # that $1 is treated as a placeholder rather than the number 1)
21
+ _PLACEHOLDER = re.compile(r"%\(\w+\)s|%s|\$\d+|(?<![:\w]):\w+")
22
+
23
+ # 4. numeric literals: standalone numbers only, never the trailing digits of an
24
+ # identifier like col1 (lookbehind rejects a preceding word char or dot)
25
+ _NUMBER = re.compile(r"(?<![\w.])\d+(?:\.\d+)?(?:[eE][+-]?\d+)?")
26
+
27
+ # 5. whitespace runs
28
+ _WS = re.compile(r"\s+")
29
+
30
+ # 6. IN (?, ?, ?) -> IN (?)
31
+ _IN_LIST = re.compile(r"(?i)\bIN\s*\(\s*\?(?:\s*,\s*\?)*\s*\)")
32
+
33
+ # 7. multi-row VALUES (?, ?), (?, ?), ... -> VALUES (?, ?) (keep first tuple)
34
+ _MULTI_VALUES = re.compile(r"(?i)(\bVALUES\s*)(\([^()]*\))(?:\s*,\s*\([^()]*\))+")
35
+
36
+
37
+ def normalize_sql(sql: str) -> str:
38
+ """Return a literal-free, whitespace-collapsed signature for *sql*."""
39
+ if not sql:
40
+ return ""
41
+ s = sql
42
+ s = _LINE_COMMENT.sub(" ", s)
43
+ s = _BLOCK_COMMENT.sub(" ", s)
44
+ s = _STRING.sub("?", s)
45
+ s = _PLACEHOLDER.sub("?", s)
46
+ s = _NUMBER.sub("?", s)
47
+ s = _WS.sub(" ", s).strip()
48
+ s = _IN_LIST.sub("IN (?)", s)
49
+ s = _MULTI_VALUES.sub(r"\1\2", s)
50
+ return s
51
+
52
+
53
+ def group_key(normalized: str) -> str:
54
+ """Stable 12-hex-char group id derived from a normalized statement."""
55
+ return hashlib.sha1(normalized.encode()).hexdigest()[:12]
wherewent/recorder.py ADDED
@@ -0,0 +1,383 @@
1
+ """The recorder: class-level SQLAlchemy event hooks + finalize/report.
2
+
3
+ Design invariants (see build contract):
4
+ * Never crash the host job: every hook body is wrapped in try/except.
5
+ * Never store parameter VALUES — only counts, durations, and normalized SQL.
6
+ * Cheap hot path: memoized library detection, bounded normalization cache,
7
+ full stack capture only for the first 5 samples of each group.
8
+ * finalize() prints exactly once, to stderr, via atexit AND a signal handler.
9
+ """
10
+
11
+ import atexit
12
+ import json
13
+ import os
14
+ import signal
15
+ import statistics
16
+ import sys
17
+ from time import perf_counter
18
+
19
+ from . import report, rules
20
+ from .callsite import capture_stack, resolve_call_site
21
+ from .normalize import group_key, normalize_sql
22
+ from .stats import GroupSnapshot, RunSnapshot
23
+
24
+ _NORM_CACHE_MAX = 4096
25
+
26
+
27
+ class Recorder:
28
+ def __init__(self):
29
+ self.installed = False
30
+ self.finalized = False
31
+ self.save_path = None
32
+
33
+ self.start_perf = None
34
+ self._start_times = None
35
+
36
+ # per-group accumulators keyed by group_key
37
+ self.groups = {}
38
+ # normalization cache keyed by raw SQL string (bounded)
39
+ self._norm_cache = {}
40
+ # per-execution start times keyed by id(context)
41
+ self._exec_start = {}
42
+ # per-connection transaction generation (kept for --save completeness)
43
+ self._txn_gen = {}
44
+
45
+ self.total_queries = 0
46
+ self.total_commits = 0
47
+ self.total_rollbacks = 0
48
+ self.total_rows = 0
49
+ self.db_time = 0.0
50
+ self.overhead_time = 0.0
51
+
52
+ self.commit_time = 0.0
53
+ self.commit_measurable = False # only True once a dialect is wrapped
54
+
55
+ self.sqlalchemy_active = False
56
+ self._prev_handlers = {}
57
+
58
+ # -- installation ----------------------------------------------------------
59
+
60
+ def install(self, save_path=None):
61
+ if self.installed:
62
+ return
63
+ self.installed = True
64
+ self.save_path = save_path or None
65
+ self.start_perf = perf_counter()
66
+ try:
67
+ self._start_times = os.times()
68
+ except Exception:
69
+ self._start_times = None
70
+
71
+ try:
72
+ import sqlalchemy # noqa: F401
73
+ from sqlalchemy import event
74
+ from sqlalchemy.engine import Engine
75
+
76
+ # class-level: every engine in the process is captured, zero config
77
+ event.listen(Engine, "before_cursor_execute", self._before, named=True)
78
+ event.listen(Engine, "after_cursor_execute", self._after, named=True)
79
+ event.listen(Engine, "begin", self._on_begin)
80
+ event.listen(Engine, "commit", self._on_commit)
81
+ event.listen(Engine, "rollback", self._on_rollback)
82
+ event.listen(Engine, "engine_connect", self._on_connect)
83
+ self.sqlalchemy_active = True
84
+ except Exception:
85
+ self.sqlalchemy_active = False
86
+
87
+ atexit.register(self.finalize)
88
+ self._install_signal_handlers()
89
+
90
+ def _install_signal_handlers(self):
91
+ for sig in (signal.SIGINT, signal.SIGTERM):
92
+ try:
93
+ self._prev_handlers[sig] = signal.getsignal(sig)
94
+ signal.signal(sig, self._handle_signal)
95
+ except Exception:
96
+ pass
97
+
98
+ def _handle_signal(self, signum, frame):
99
+ # Ctrl-C mid-job MUST still produce the report.
100
+ self.finalize()
101
+ try:
102
+ signal.signal(signum, signal.SIG_DFL)
103
+ except Exception:
104
+ pass
105
+ sys.exit(130 if signum == signal.SIGINT else 143)
106
+
107
+ # -- statement hooks -------------------------------------------------------
108
+
109
+ def _before(self, **kw):
110
+ t0 = perf_counter()
111
+ try:
112
+ ctx = kw.get("context")
113
+ if ctx is not None:
114
+ self._exec_start[id(ctx)] = perf_counter()
115
+ except Exception:
116
+ pass
117
+ finally:
118
+ self.overhead_time += perf_counter() - t0
119
+
120
+ def _after(self, **kw):
121
+ t0 = perf_counter()
122
+ try:
123
+ ctx = kw.get("context")
124
+ statement = kw.get("statement") or ""
125
+ cursor = kw.get("cursor")
126
+ executemany = bool(kw.get("executemany"))
127
+
128
+ start = self._exec_start.pop(id(ctx), None) if ctx is not None else None
129
+ duration = (perf_counter() - start) if start is not None else 0.0
130
+
131
+ self.sqlalchemy_active = True
132
+ self.total_queries += 1
133
+ self.db_time += duration
134
+
135
+ key, normalized = self._normalize_cached(statement)
136
+ g = self.groups.get(key)
137
+ if g is None:
138
+ g = {
139
+ "normalized_sql": normalized,
140
+ "calls": 0,
141
+ "durations": [],
142
+ "total_time": 0.0,
143
+ "rows": 0,
144
+ "executemany_calls": 0,
145
+ "call_sites": {}, # (file,line,func) -> count
146
+ "sample_stacks": [],
147
+ }
148
+ self.groups[key] = g
149
+
150
+ g["calls"] += 1
151
+ g["durations"].append(duration)
152
+ g["total_time"] += duration
153
+ if executemany:
154
+ g["executemany_calls"] += 1
155
+
156
+ try:
157
+ rc = cursor.rowcount if cursor is not None else None
158
+ if rc is not None and rc >= 0:
159
+ g["rows"] += rc
160
+ self.total_rows += rc
161
+ except Exception:
162
+ pass
163
+
164
+ site = resolve_call_site(skip=1)
165
+ if site is not None:
166
+ g["call_sites"][site] = g["call_sites"].get(site, 0) + 1
167
+
168
+ # full stack capture only for the first 5 executions per group
169
+ if g["calls"] <= 5:
170
+ g["sample_stacks"].append(capture_stack())
171
+ except Exception:
172
+ pass
173
+ finally:
174
+ self.overhead_time += perf_counter() - t0
175
+
176
+ # -- transaction hooks -----------------------------------------------------
177
+
178
+ def _on_begin(self, conn):
179
+ t0 = perf_counter()
180
+ try:
181
+ self._txn_gen[id(conn)] = self._txn_gen.get(id(conn), 0) + 1
182
+ except Exception:
183
+ pass
184
+ finally:
185
+ self.overhead_time += perf_counter() - t0
186
+
187
+ def _on_commit(self, conn):
188
+ t0 = perf_counter()
189
+ try:
190
+ self.total_commits += 1
191
+ self._txn_gen[id(conn)] = self._txn_gen.get(id(conn), 0) + 1
192
+ except Exception:
193
+ pass
194
+ finally:
195
+ self.overhead_time += perf_counter() - t0
196
+
197
+ def _on_rollback(self, conn):
198
+ t0 = perf_counter()
199
+ try:
200
+ self.total_rollbacks += 1
201
+ self._txn_gen[id(conn)] = self._txn_gen.get(id(conn), 0) + 1
202
+ except Exception:
203
+ pass
204
+ finally:
205
+ self.overhead_time += perf_counter() - t0
206
+
207
+ def _on_connect(self, conn, *args):
208
+ # engine_connect fires with the Connection positionally (2.0: (conn);
209
+ # older: (conn, branch)). There is no after-commit event, so wrap the
210
+ # dialect's do_commit / do_rollback with a perf_counter timer here.
211
+ # Idempotent per dialect.
212
+ t0 = perf_counter()
213
+ try:
214
+ dialect = getattr(conn, "dialect", None)
215
+ if dialect is not None and not getattr(dialect, "_wherewent_wrapped", False):
216
+ self._wrap_dialect(dialect)
217
+ except Exception:
218
+ pass
219
+ finally:
220
+ self.overhead_time += perf_counter() - t0
221
+
222
+ def _wrap_dialect(self, dialect):
223
+ rec = self
224
+ orig_commit = dialect.do_commit
225
+ orig_rollback = dialect.do_rollback
226
+
227
+ def timed_commit(dbapi_connection, _orig=orig_commit):
228
+ t = perf_counter()
229
+ try:
230
+ return _orig(dbapi_connection)
231
+ finally:
232
+ try:
233
+ rec.commit_time += perf_counter() - t
234
+ except Exception:
235
+ pass
236
+
237
+ def timed_rollback(dbapi_connection, _orig=orig_rollback):
238
+ t = perf_counter()
239
+ try:
240
+ return _orig(dbapi_connection)
241
+ finally:
242
+ try:
243
+ rec.commit_time += perf_counter() - t
244
+ except Exception:
245
+ pass
246
+
247
+ dialect.do_commit = timed_commit
248
+ dialect.do_rollback = timed_rollback
249
+ dialect._wherewent_wrapped = True
250
+ self.commit_measurable = True
251
+
252
+ # -- normalization cache ---------------------------------------------------
253
+
254
+ def _normalize_cached(self, statement):
255
+ cached = self._norm_cache.get(statement)
256
+ if cached is not None:
257
+ return cached
258
+ normalized = normalize_sql(statement)
259
+ key = group_key(normalized)
260
+ if len(self._norm_cache) >= _NORM_CACHE_MAX:
261
+ self._norm_cache.clear() # bounded: on overflow just drop everything
262
+ self._norm_cache[statement] = (key, normalized)
263
+ return key, normalized
264
+
265
+ # -- snapshot / finalize ---------------------------------------------------
266
+
267
+ def snapshot(self) -> RunSnapshot:
268
+ wall = (perf_counter() - self.start_perf) if self.start_perf else 0.0
269
+
270
+ cpu = None
271
+ if self._start_times is not None:
272
+ try:
273
+ now = os.times()
274
+ cpu = (now.user - self._start_times.user) + (
275
+ now.system - self._start_times.system
276
+ )
277
+ except Exception:
278
+ cpu = None
279
+
280
+ groups = []
281
+ for key, g in self.groups.items():
282
+ durations = g["durations"]
283
+ median = statistics.median(durations) if durations else 0.0
284
+ site = None
285
+ if g["call_sites"]:
286
+ site = max(g["call_sites"].items(), key=lambda kv: kv[1])[0]
287
+ groups.append(
288
+ GroupSnapshot(
289
+ key=key,
290
+ normalized_sql=g["normalized_sql"],
291
+ calls=g["calls"],
292
+ total_time=g["total_time"],
293
+ median=median,
294
+ rows=g["rows"],
295
+ executemany_calls=g["executemany_calls"],
296
+ call_site=site,
297
+ )
298
+ )
299
+
300
+ commit_time = self.commit_time if self.commit_measurable else None
301
+
302
+ return RunSnapshot(
303
+ wall_time=wall,
304
+ cpu_time=cpu,
305
+ total_queries=self.total_queries,
306
+ total_commits=self.total_commits,
307
+ total_rollbacks=self.total_rollbacks,
308
+ commit_time=commit_time,
309
+ total_rows=self.total_rows,
310
+ db_time=self.db_time,
311
+ overhead_time=self.overhead_time,
312
+ sqlalchemy_active=self.sqlalchemy_active,
313
+ groups=groups,
314
+ )
315
+
316
+ def finalize(self):
317
+ if self.finalized:
318
+ return
319
+ self.finalized = True
320
+ try:
321
+ run = self.snapshot()
322
+ findings = rules.evaluate(run)
323
+ text = report.render(run, findings)
324
+ sys.stderr.write(text + "\n")
325
+ sys.stderr.flush()
326
+ if self.save_path:
327
+ self._write_json(run, findings)
328
+ except Exception:
329
+ pass
330
+
331
+ def _write_json(self, run: RunSnapshot, findings):
332
+ # groups in the snapshot are sorted for the report; for JSON we walk the
333
+ # raw accumulators so we can attach the captured sample stacks too.
334
+ group_by_key = {g.key: g for g in run.groups}
335
+ groups_json = []
336
+ for key, gs in group_by_key.items():
337
+ raw = self.groups.get(key, {})
338
+ groups_json.append(
339
+ {
340
+ "key": gs.key,
341
+ "normalized_sql": gs.normalized_sql,
342
+ "calls": gs.calls,
343
+ "total_time": gs.total_time,
344
+ "median": gs.median,
345
+ "rows": gs.rows,
346
+ "executemany_calls": gs.executemany_calls,
347
+ "call_site": list(gs.call_site) if gs.call_site else None,
348
+ "sample_stacks": raw.get("sample_stacks", []),
349
+ }
350
+ )
351
+
352
+ payload = {
353
+ "wall_time": run.wall_time,
354
+ "cpu_time": run.cpu_time,
355
+ "total_queries": run.total_queries,
356
+ "total_commits": run.total_commits,
357
+ "total_rollbacks": run.total_rollbacks,
358
+ "commit_time": run.commit_time,
359
+ "total_rows": run.total_rows,
360
+ "db_time": run.db_time,
361
+ "overhead_time": run.overhead_time,
362
+ "sqlalchemy_active": run.sqlalchemy_active,
363
+ "groups": groups_json,
364
+ "findings": [
365
+ {"rule": f.rule, "title": f.title, "detail": f.detail, "seconds": f.seconds}
366
+ for f in findings
367
+ ],
368
+ }
369
+ try:
370
+ with open(self.save_path, "w") as fh:
371
+ json.dump(payload, fh, indent=2)
372
+ except Exception:
373
+ pass
374
+
375
+
376
+ # module-level singleton
377
+ recorder = Recorder()
378
+
379
+
380
+ def install_from_env() -> None:
381
+ """Entry point called by the shim: install using WHEREWENT_SAVE from env."""
382
+ save = os.environ.get("WHEREWENT_SAVE") or None
383
+ recorder.install(save)
wherewent/report.py ADDED
@@ -0,0 +1,81 @@
1
+ """Plain-text report renderer (~100 columns, stderr-friendly).
2
+
3
+ Golden rule: never print a guessed number. Anything unmeasurable prints as "—".
4
+ """
5
+
6
+ from .stats import Finding, RunSnapshot
7
+
8
+ _WIDTH = 100
9
+ _SQL_COL = 48
10
+
11
+
12
+ def _pct(part: float, whole: float) -> str:
13
+ return f"{part / whole * 100:.1f}%" if whole > 0 else "—"
14
+
15
+
16
+ def render(run: RunSnapshot, findings: "list[Finding]") -> str:
17
+ wall = run.wall_time
18
+ lines = []
19
+ lines.append("=" * _WIDTH)
20
+ lines.append("wherewent — SQL flight recorder")
21
+ lines.append("-" * _WIDTH)
22
+
23
+ if run.cpu_time is not None and wall > 0:
24
+ cpu_str = f"{run.cpu_time:.2f}s ({run.cpu_time / wall * 100:.0f}% CPU busy)"
25
+ else:
26
+ cpu_str = "—"
27
+ lines.append(
28
+ f"wall: {wall:.2f}s cpu: {cpu_str} "
29
+ f"queries: {run.total_queries:,} commits: {run.total_commits:,} "
30
+ f"rollbacks: {run.total_rollbacks:,}"
31
+ )
32
+ lines.append(
33
+ f"in-DB time: {run.db_time:.2f}s ({_pct(run.db_time, wall)} of wall; "
34
+ f"app-observed: includes network+driver+server)"
35
+ )
36
+ commit_str = f"{run.commit_time:.2f}s" if run.commit_time is not None else "—"
37
+ lines.append(f"commit time: {commit_str} total rows: {run.total_rows:,}")
38
+ lines.append(
39
+ f"recording added ~{run.overhead_time:.2f}s (~{_pct(run.overhead_time, wall)} of wall)"
40
+ )
41
+ lines.append("=" * _WIDTH)
42
+
43
+ if not run.sqlalchemy_active:
44
+ lines.append("SQLAlchemy was not imported by this process — nothing recorded.")
45
+ lines.append("=" * _WIDTH)
46
+ return "\n".join(lines)
47
+
48
+ # --- query group table (top 15 by total app time) -------------------------
49
+ groups = sorted(run.groups, key=lambda g: g.total_time, reverse=True)[:15]
50
+ lines.append(
51
+ f"{'QUERY GROUP':<{_SQL_COL}} {'CALLS':>8} {'TOTAL':>9} {'MEDIAN':>10} CALL SITE"
52
+ )
53
+ lines.append("-" * _WIDTH)
54
+ if not groups:
55
+ lines.append("(no queries recorded)")
56
+ for g in groups:
57
+ sql = g.normalized_sql
58
+ if len(sql) > _SQL_COL:
59
+ sql = sql[: _SQL_COL - 3] + "..."
60
+ if g.call_site:
61
+ site = f"{g.call_site[0]}:{g.call_site[1]} in {g.call_site[2]}"
62
+ else:
63
+ site = "—"
64
+ tot = f"{g.total_time:.2f}s"
65
+ med = f"{g.median * 1000:.2f}ms"
66
+ lines.append(f"{sql:<{_SQL_COL}} {g.calls:>8,} {tot:>9} {med:>10} {site}")
67
+
68
+ # --- findings -------------------------------------------------------------
69
+ lines.append("=" * _WIDTH)
70
+ lines.append("FINDINGS")
71
+ lines.append("-" * _WIDTH)
72
+ if not findings:
73
+ lines.append("No findings fired (thresholds: see README).")
74
+ else:
75
+ for i, f in enumerate(findings, 1):
76
+ lines.append(f"{i}. [{f.rule}] {f.title}")
77
+ for dl in f.detail.split("\n"):
78
+ lines.append(f" {dl}")
79
+ lines.append(f" ~= {f.seconds:.1f}s attributable")
80
+ lines.append("=" * _WIDTH)
81
+ return "\n".join(lines)
wherewent/rules.py ADDED
@@ -0,0 +1,119 @@
1
+ """Deterministic heuristics that turn a RunSnapshot into human findings.
2
+
3
+ Every threshold and merge rule here is part of the build contract. Numbers must
4
+ stay honest: a rule only fires when its inputs are actually measurable.
5
+ """
6
+
7
+ from .stats import Finding, RunSnapshot
8
+
9
+
10
+ def _site_str(group) -> str:
11
+ if group.call_site:
12
+ f, ln, _fn = group.call_site
13
+ return f"{f}:{ln}"
14
+ return "unknown"
15
+
16
+
17
+ def _within(a: float, b: float, frac: float) -> bool:
18
+ """True if a and b are within *frac* of each other (relative)."""
19
+ hi = max(a, b)
20
+ if hi <= 0:
21
+ return False
22
+ return (min(a, b) / hi) >= (1.0 - frac)
23
+
24
+
25
+ def evaluate(run: RunSnapshot) -> "list[Finding]":
26
+ wall = run.wall_time
27
+ if wall <= 0:
28
+ return []
29
+
30
+ cpu_busy = (run.cpu_time / wall) if (run.cpu_time is not None and wall > 0) else None
31
+
32
+ r1 = r1_group = None
33
+ r2 = None
34
+ r3 = None
35
+
36
+ # --- R1: chatty query group (worst by total_time) -------------------------
37
+ candidates = [
38
+ g for g in run.groups
39
+ if g.calls > 1000 and g.total_time > 0.10 * wall and g.median < 0.005
40
+ ]
41
+ if candidates:
42
+ g = max(candidates, key=lambda x: x.total_time)
43
+ r1_group = g
44
+ detail = (
45
+ f"{g.calls:,} calls x {g.median * 1000:.2f}ms median "
46
+ f"~= {g.total_time:.1f}s = {g.total_time / wall * 100:.0f}% of {wall:.1f}s wall, "
47
+ f"at {_site_str(g)}. Batch it (executemany / IN-list / JOIN)."
48
+ )
49
+ r1 = Finding("R1", "Chatty query group", detail, g.total_time)
50
+
51
+ # --- R2: commit-per-row ---------------------------------------------------
52
+ if (
53
+ run.total_commits > 100
54
+ and run.commit_time is not None
55
+ and (run.total_rows / run.total_commits) < 10
56
+ and run.commit_time > 0.05 * wall
57
+ ):
58
+ avg = run.total_rows / run.total_commits
59
+ detail = (
60
+ f"{run.total_commits:,} commits for {run.total_rows:,} rows "
61
+ f"({avg:.1f} rows/commit), {run.commit_time:.1f}s in commit "
62
+ f"= {run.commit_time / wall * 100:.0f}% of wall. "
63
+ f"Batch to 1,000+ rows per transaction."
64
+ )
65
+ r2 = Finding("R2", "Commit-per-row", detail, run.commit_time)
66
+
67
+ # --- R3: DB-wait dominates ------------------------------------------------
68
+ if run.db_time > 0.60 * wall and cpu_busy is not None and cpu_busy < 0.30:
69
+ detail = (
70
+ f"DB {run.db_time:.1f}s of {wall:.1f}s wall "
71
+ f"({run.db_time / wall * 100:.0f}%), CPU busy only {cpu_busy * 100:.0f}% "
72
+ f"-> round-trip bound, not compute bound."
73
+ )
74
+ r3 = Finding("R3", "DB-wait dominates", detail, run.db_time)
75
+
76
+ # --- merge / fold ---------------------------------------------------------
77
+ findings: "list[Finding]" = []
78
+ consumed = set()
79
+
80
+ # R1 + R2 same-loop merge (calls ~= commits within 25%)
81
+ if r1 is not None and r2 is not None and _within(r1_group.calls, run.total_commits, 0.25):
82
+ consumed.update({"r1", "r2"})
83
+ rule = "R1+R2"
84
+ detail = r1.detail + "\n" + r2.detail
85
+ seconds = r1.seconds + r2.seconds
86
+ if r3 is not None:
87
+ consumed.add("r3")
88
+ rule = "R1+R2+R3"
89
+ detail = detail + "\n" + r3.detail # R3 overlaps R1's query time; don't add seconds
90
+ findings.append(Finding(rule, "commit-per-row loop", detail, seconds))
91
+
92
+ # R3 + exactly one other, where that other explains >= 80% of the DB wait
93
+ if "r3" not in consumed and r3 is not None:
94
+ other = other_name = None
95
+ if r1 is not None and r2 is None:
96
+ other, other_name = r1, "r1"
97
+ elif r2 is not None and r1 is None:
98
+ other, other_name = r2, "r2"
99
+ if other is not None and other.seconds >= 0.8 * run.db_time:
100
+ consumed.update({"r3", other_name})
101
+ if other_name == "r1":
102
+ # R1's query time is a subset of db_time: attribute db_time, no double-count
103
+ seconds = max(other.seconds, r3.seconds)
104
+ rule = "R1+R3"
105
+ else:
106
+ # commit time is disjoint from cursor-execute db_time: additive
107
+ seconds = other.seconds + r3.seconds
108
+ rule = "R2+R3"
109
+ findings.append(Finding(rule, other.title, other.detail + "\n" + r3.detail, seconds))
110
+
111
+ # emit any survivors that were not folded into a merge
112
+ for name, f in (("r1", r1), ("r2", r2), ("r3", r3)):
113
+ if f is not None and name not in consumed:
114
+ findings.append(f)
115
+
116
+ # suppress trivia, sort, cap at 3
117
+ findings = [f for f in findings if f.seconds >= 0.05 * wall]
118
+ findings.sort(key=lambda f: f.seconds, reverse=True)
119
+ return findings[:3]
wherewent/stats.py ADDED
@@ -0,0 +1,42 @@
1
+ """Shared dataclasses — the load-bearing contract that rules/report/tests depend on.
2
+
3
+ Field names and types here are frozen: do not rename or reorder without updating
4
+ every consumer.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+
10
+ @dataclass
11
+ class GroupSnapshot:
12
+ key: str # 12-hex-char sha1 prefix of normalized_sql
13
+ normalized_sql: str
14
+ calls: int
15
+ total_time: float # seconds, app-observed (includes driver+network+server)
16
+ median: float # seconds; 0.0 if no samples
17
+ rows: int # summed cursor.rowcount where >= 0, else 0 contribution
18
+ executemany_calls: int
19
+ call_site: "tuple[str, int, str] | None" # (file, line, function); None if unresolved
20
+
21
+
22
+ @dataclass
23
+ class RunSnapshot:
24
+ wall_time: float # seconds
25
+ cpu_time: "float | None" # process user+sys CPU seconds (os.times); None if unmeasurable
26
+ total_queries: int
27
+ total_commits: int
28
+ total_rollbacks: int
29
+ commit_time: "float | None" # seconds inside DBAPI commit; None if not measurable
30
+ total_rows: int
31
+ db_time: float # sum of all cursor-execute durations
32
+ overhead_time: float # recorder's own measured hook time, seconds
33
+ sqlalchemy_active: bool # were hooks installed and did SQLAlchemy get used
34
+ groups: "list[GroupSnapshot]" = field(default_factory=list)
35
+
36
+
37
+ @dataclass
38
+ class Finding:
39
+ rule: str # "R1" | "R2" | "R3" | "R1+R2" | "R1+R2+R3" | "R1+R3" | "R2+R3"
40
+ title: str # one line
41
+ detail: str # multi-line: MUST show the arithmetic
42
+ seconds: float # estimated seconds attributable
@@ -0,0 +1,204 @@
1
+ Metadata-Version: 2.4
2
+ Name: wherewent
3
+ Version: 0.1.0
4
+ Summary: Zero-config SQL flight recorder that answers 'why did this Python batch job take so long?'
5
+ Author-email: Habiba Faisal <habibafaisal8@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/habibafaisal/wherewent
8
+ Project-URL: Repository, https://github.com/habibafaisal/wherewent
9
+ Project-URL: Issues, https://github.com/habibafaisal/wherewent/issues
10
+ Project-URL: Changelog, https://github.com/habibafaisal/wherewent/blob/main/CHANGELOG.md
11
+ Keywords: profiling,performance,sqlalchemy,postgresql,sql,observability,batch,n+1,orm
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Database
20
+ Classifier: Topic :: Software Development :: Debuggers
21
+ Classifier: Topic :: System :: Monitoring
22
+ Classifier: Environment :: Console
23
+ Classifier: Operating System :: OS Independent
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Provides-Extra: psutil
28
+ Requires-Dist: psutil; extra == "psutil"
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest; extra == "dev"
31
+ Requires-Dist: sqlalchemy>=2.0; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ <div align="center">
35
+
36
+ # wherewent
37
+
38
+ ### Where did the time go? Find out in one command.
39
+
40
+ **A zero-config recorder that answers "why did this Python batch job take so long?"**
41
+
42
+ [![PyPI version](https://img.shields.io/pypi/v/wherewent.svg)](https://pypi.org/project/wherewent/)
43
+ [![Python versions](https://img.shields.io/pypi/pyversions/wherewent.svg)](https://pypi.org/project/wherewent/)
44
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
45
+ [![CI](https://github.com/habibafaisal/wherewent/actions/workflows/ci.yml/badge.svg)](https://github.com/habibafaisal/wherewent/actions/workflows/ci.yml)
46
+
47
+ ```bash
48
+ wherewent run python your_job.py
49
+ ```
50
+
51
+ </div>
52
+
53
+ ---
54
+
55
+ ## The 0.4ms query that costs you 5 minutes
56
+
57
+ A query can be individually fast — `0.4ms` — and still sink your job, because it's
58
+ called **500,000 times from a single line of code**. Your app burns 300 seconds on
59
+ round-trips while Postgres itself only worked for 80. Every profiler you've tried shows
60
+ you *"time spent in psycopg"* and stops there.
61
+
62
+ **wherewent shows you the calling pattern.** It groups queries by shape, counts how
63
+ often each shape ran, sums the wall time, and points at the exact `file:line` in *your*
64
+ code that fired it — then tells you, in plain English with the arithmetic shown, what to
65
+ do about it.
66
+
67
+ ```
68
+ ====================================================================================================
69
+ wherewent — SQL flight recorder
70
+ ----------------------------------------------------------------------------------------------------
71
+ wall: 26.15s cpu: 25.41s (97% CPU busy) queries: 20,004 commits: 20,001 rollbacks: 1
72
+ in-DB time: 5.46s (20.9% of wall; app-observed: includes network+driver+server)
73
+ commit time: 9.06s total rows: 20,000
74
+ recording added ~1.81s (~6.9% of wall)
75
+ ====================================================================================================
76
+ QUERY GROUP CALLS TOTAL MEDIAN CALL SITE
77
+ ----------------------------------------------------------------------------------------------------
78
+ INSERT INTO events (name, value) VALUES (?, ?) 20,000 5.46s 0.24ms demo/naive_job.py:65 in main
79
+ SELECT count(*) AS count_1 FROM events 1 0.00s 0.16ms demo/naive_job.py:71 in main
80
+ ====================================================================================================
81
+ FINDINGS
82
+ ----------------------------------------------------------------------------------------------------
83
+ 1. [R1+R2] commit-per-row loop
84
+ 20,000 calls x 0.24ms median ~= 5.5s = 21% of 26.1s wall, at demo/naive_job.py:65. Batch it.
85
+ 20,001 commits for 20,000 rows (1.0 rows/commit), 9.1s in commit = 35% of wall. Batch to 1,000+ rows/txn.
86
+ ~= 14.5s attributable
87
+ ====================================================================================================
88
+ ```
89
+
90
+ ## Why it's different
91
+
92
+ | | Sampling profilers | APM / tracing | **wherewent** |
93
+ |---|:---:|:---:|:---:|
94
+ | Zero code changes | ✅ | ❌ | ✅ |
95
+ | Groups queries by shape | ❌ | ⚠️ | ✅ |
96
+ | Blames *your* call site | ⚠️ | ⚠️ | ✅ |
97
+ | Tells you the fix | ❌ | ❌ | ✅ |
98
+ | Runs anywhere, no server | ✅ | ❌ | ✅ |
99
+ | Works on a Ctrl-C'd partial run | ❌ | ⚠️ | ✅ |
100
+
101
+ ## Install
102
+
103
+ ```bash
104
+ pip install wherewent
105
+ ```
106
+
107
+ That's it — the recorder is **pure standard library**. You only need SQLAlchemy
108
+ because *your job* already uses it.
109
+
110
+ ## Use it
111
+
112
+ Wrap any command. Your script runs **completely unmodified** — no imports, no decorators,
113
+ no config:
114
+
115
+ ```bash
116
+ wherewent run python your_job.py --some arg
117
+ wherewent run python -m your_package
118
+ wherewent run --save run.json python your_job.py # also dump machine-readable JSON
119
+ ```
120
+
121
+ - The report prints to **stderr** at exit; your job's own stdout/stderr pass through untouched.
122
+ - **Ctrl-C still produces a report.** Sampling the first 5 minutes of a 14-hour job is the
123
+ main use case — partial data is the point.
124
+ - **It can never crash or corrupt your job.** Every hook body is wrapped so the recorder
125
+ fails silent rather than taking your run down with it.
126
+ - **It never records your data.** Only query *shapes* and *counts* are kept — literal
127
+ values and bind parameters are stripped before anything is stored.
128
+
129
+ ### Try the built-in demo
130
+
131
+ ```bash
132
+ git clone https://github.com/habibafaisal/wherewent && cd wherewent
133
+ pip install -e ".[dev]"
134
+ wherewent run python demo/naive_job.py # watch the R1+R2 finding fire
135
+ python demo/benchmark.py # naive vs fixed, with the overhead gate
136
+ ```
137
+
138
+ ## How it works
139
+
140
+ 1. **Injects itself** into the target process via a `PYTHONPATH` sitecustomize shim — no
141
+ changes to your code, no wrapper imports.
142
+ 2. **Listens at the class level** — `event.listen(sqlalchemy.engine.Engine, ...)` — so
143
+ *every* engine your app creates is captured automatically, config-free.
144
+ 3. **Normalizes each statement** into a query *group*: literals, bind params, `IN`-lists
145
+ and multi-row `VALUES` collapse, so a million distinct inserts become one honest row.
146
+ 4. **Resolves the call site** by walking the stack past library frames to the first line
147
+ of *your* code — cheaply: cached by filename, full stacks only for the first 5 samples
148
+ per group, so the hot path stays cheap enough to hit its overhead budget.
149
+ 5. **Fires deterministic findings** from three rules, each showing its arithmetic.
150
+
151
+ ### The findings engine
152
+
153
+ | Rule | Fires when | Tells you |
154
+ |---|---|---|
155
+ | **R1 — chatty group** | > 1,000 calls, > 10% of wall, median < 5ms | A fast query is called too many times — batch it (`executemany` / `IN`-list / `JOIN`). |
156
+ | **R2 — commit-per-row** | > 100 commits, < 10 rows/commit, > 5% of wall in commit | You're committing per row — batch to 1,000+ rows per transaction. |
157
+ | **R3 — DB-wait bound** | in-DB time > 60% of wall, CPU busy < 30% | The job is round-trip bound, not compute bound. |
158
+
159
+ Findings that share a root cause **merge** (e.g. `R1+R2`), everything under 5% of wall is
160
+ suppressed, and at most the top 3 are shown — ranked by seconds attributable.
161
+
162
+ **Every number is honest.** Query times are labelled *app-observed* (they include network,
163
+ driver, and server time — not just Postgres). Anything that can't be measured prints `—`,
164
+ never a guess. wherewent even times *its own hooks* and reports the overhead it added.
165
+
166
+ ## Roadmap — help wanted 🙌
167
+
168
+ wherewent is built to grow **beyond SQLAlchemy**. Seven of its eight modules —
169
+ normalization, call-site resolution, the stats model, the rules engine, the report, the
170
+ CLI, and the injection shim — are already **framework-agnostic**. They operate on a plain
171
+ `RunSnapshot` of query events. Only `recorder.py`, which binds SQLAlchemy's event system,
172
+ is framework-specific.
173
+
174
+ **That means a new backend is a well-contained contribution:** capture query
175
+ start/end/rowcount/txn events from another driver, feed the same `RunSnapshot`, and the
176
+ entire findings-and-report pipeline works for free. Good first backends:
177
+
178
+ - [ ] **Raw `psycopg` / `psycopg2`** — cursor subclass or connection factory hook
179
+ - [ ] **`asyncpg` / async SQLAlchemy** — the async execution path
180
+ - [ ] **Django ORM** — via `connection.execute_wrapper`
181
+ - [ ] **Generic DB-API 2.0** — a monkeypatch-free `Cursor` proxy
182
+ - [ ] New findings rules (N+1 `SELECT` detection, lock-wait, seq-scan heuristics)
183
+
184
+ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the backend contract and the **< 15% overhead
185
+ gate** that every capture path must pass.
186
+
187
+ ## Limitations (today)
188
+
189
+ - SQLAlchemy **2.x, synchronous** only (1.4 may work; async does not yet).
190
+ - Single process — no multiprocessing or async fan-out.
191
+ - Query times are app-observed (network + driver + server), by design.
192
+ - Commit timing is obtained by wrapping the dialect's commit; if that wrap fails it prints `—`.
193
+
194
+ These are the honest edges of a validation prototype, not permanent walls — see the roadmap.
195
+
196
+ ## Contributing
197
+
198
+ Contributions are very welcome — new backends, new rules, docs, bug reports. Start with
199
+ [`CONTRIBUTING.md`](CONTRIBUTING.md), open an issue to discuss anything substantial, and
200
+ run `pytest && python demo/benchmark.py` before you push.
201
+
202
+ ## License
203
+
204
+ [MIT](LICENSE) © 2026 Habiba Faisal
@@ -0,0 +1,16 @@
1
+ wherewent/__init__.py,sha256=d62PR2Qhvc7BZCSDvffjx6dpoO9soQtS6gVaiSwiIJc,108
2
+ wherewent/callsite.py,sha256=BfLjAqKAkgipiLeNcXc6WQolXS-EwEdhoLQyINhhqjg,3172
3
+ wherewent/cli.py,sha256=xlm2mr3IZU8B8mKwxErO4KgTrxp-pVoUF31yF_h7or8,2577
4
+ wherewent/normalize.py,sha256=1E0zerQefcmuwXHq7qlpvqngh1sorExavhIzhUUV5zU,1950
5
+ wherewent/recorder.py,sha256=upykLxIjcutJMNm6RXxqkWTmdJttiz_uUqK1RuMbjww,13219
6
+ wherewent/report.py,sha256=1cwXhLXFme3e8VzYNeTXR8BHMyAG1BsSR7mDJZHAVB8,2965
7
+ wherewent/rules.py,sha256=EvP-WTv2SlLxIgzUKf8A9kV8bjzkcKn03V_7pXQlDYc,4714
8
+ wherewent/stats.py,sha256=EuQ7EV5JsFWKdPIzhvJoVXXX3imet7PUUJoS2vZmV0I,1647
9
+ wherewent/_shim/__init__.py,sha256=UQaL9OROUZV4txD12NURZdAUl513ugLvXlWHx54XBk4,147
10
+ wherewent/_shim/sitecustomize.py,sha256=rjww2F6hx8ILrZsV_Q-cyg905pNfrmVwjFyBEwQHv-c,545
11
+ wherewent-0.1.0.dist-info/licenses/LICENSE,sha256=Y4N2_d6kVd8vbQz5NTh7fdq9YsRC5u6gzcbyia_ZFgw,1070
12
+ wherewent-0.1.0.dist-info/METADATA,sha256=7BbXzH065KiNT0812lZX9p3G1gJzPwDpik35XTKNXmw,9672
13
+ wherewent-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
14
+ wherewent-0.1.0.dist-info/entry_points.txt,sha256=rFsqA2cAQZ1cy-8EtkKXHppn4fJ61-llJvomxf67lHw,49
15
+ wherewent-0.1.0.dist-info/top_level.txt,sha256=LBzV2Qrv0W5rgMSKVYIJJFt9MNeDbBCsPPNN9kYfLVU,10
16
+ wherewent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wherewent = wherewent.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Habiba Faisal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ wherewent