Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,46 @@ name: Python tests

on:
push:
branches: [master]
pull_request:
workflow_dispatch:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13']
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']

steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v6
with:
enable-cache: true

- name: Run tests (non-slow, includes leak scenarios)
run: uv run --python ${{ matrix.python-version }} pytest -q

integration:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
- uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}
enable-cache: true

- name: Run tests
run: uv run python -m unittest discover -s tests
- name: Run full in-process dump smoke tests
run: uv run --python ${{ matrix.python-version }} pytest -q -m slow --no-header -p no:cacheprovider
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ CLI extras for the interactive analysis UI:
uv add '.[cli]'
```

## when to use objex

objex answers retention questions: "this object count keeps growing -- what is
still holding these objects, and from which module/frame root?" It captures via
fork-and-dump (near-zero pause for the target) into a portable single-file
SQLite artifact you analyze offline, with labeled path-to-root attribution.
For allocation-site questions ("what code path is allocating all this
memory?"), use [memray](https://github.com/bloomberg/memray) or stdlib
`tracemalloc` instead; see
[docs/2026-08-06_leak_tool_landscape.md](docs/2026-08-06_leak_tool_landscape.md)
for the full tool landscape.

# workflow

1- collect data from a running process (this comines well with `os.fork`)
Expand All @@ -41,6 +53,31 @@ RSS memory was 190.43MiB; 151.0MiB (79.8%) found in 622,108 python objects
(Type "help" for options.)
```

# agent/scripting usage

Every explorer query is available as a one-shot, pipeable JSON invocation via
the `q` subcommand -- no REPL needed:

```bash
python -m objex q analysis.db summary # {"object_count": ..., ...}
python -m objex q analysis.db root-summary sample_size=200
python -m objex q analysis.db go q=SomeType # resolve a type/path query
python -m objex q analysis.db path-to-module id=4211
```

Endpoints mirror the web API: `summary`, `object`, `random`, `referents`,
`referrers`, `type-search`, `top-types`, `largest-objects`, `root-summary`,
`path-to-module`, `path-to-frame`, `go`, `marks`, `mark`. Parameters are
`key=value` tokens. Exit code is 0 on success, 1 otherwise (error payloads are
JSON too).

There is also a read-only SQL escape hatch (schema documented in
`objex/schema.py`):

```bash
python -m objex q analysis.db sql "SELECT name, count(*) FROM pytype GROUP BY name ORDER BY 2 DESC LIMIT 10"
```

# how to debug a memory leak

Assuming the process has leaked significantly (e.g. doubled or more in memory footprint since it started),
Expand Down
22 changes: 5 additions & 17 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,23 @@
## ROADMAP

Extensions
- collections.deque, collections.defaultdict
- frame, func, code etc
- old style classes?

Tests
- CI
- "close the loop" on dump -- check that DB was written CORRECTLY not just that it didn't crash

Py3 Support
- briefly worked, atrophied w/out tests
- old-style classes

CLI
- better commands / sub-commands / flags /etc (face interface?)
- help

Library Meta-Data in dump
- detect version mismatch between export and exploration

Known warts
- opening `Reader` directly on an unindexed collection DB is pathological
(the one-time attributed-size build runs without indices and can take
minutes); users are directed through make-analysis-db instead.

## Extra export data

- Truncated repr for user classes and strings (dicts, lists, etc. can be reconstructed already)
Expand Down Expand Up @@ -72,16 +70,6 @@ Other:
* convenience function for fork-and-export
* support fork() equivalent for Windows (it borks on sockets, but that's fine for us)


## Traversal

* Iterators are an example of a group of "builtin" (aka implemented in
C) types that can hold onto a reference that's not accessible from
Python. There are listerators dictkey iterators, dictvalue
iterators, set iterators, etc. Luckily, like dictproxy they only
hold onto one reference and it's the relevant container. Might be
able to develop a heuristic around these kinds of types.

## CLI

* Subcommands (possibly separate commands once export/explore apps are separated)
Expand Down
78 changes: 78 additions & 0 deletions docs/2026-08-06_leak_tool_landscape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Python memory-leak tooling landscape and objex's niche

Two tool families exist for hunting Python memory leaks; objex is in the
second, thinner one.

## Allocation-site profilers

These answer "*where* is memory being allocated": they find growth sites but
cannot answer "who is still holding this object" -- the retention question.

- [tracemalloc](https://docs.python.org/3/library/tracemalloc.html) -- stdlib
since 3.4, maintained with CPython. Line-level allocation attribution;
Python allocations only, blind to C extensions.
- [memray](https://github.com/bloomberg/memray) -- Bloomberg; actively
maintained. Traces native + Python allocations, flamegraphs, <5% overhead,
Linux/macOS. The current default recommendation for allocation questions.
- [memory_profiler](https://github.com/pythonprofilers/memory_profiler) --
line-by-line RSS deltas; effectively unmaintained (last release 2022),
mostly superseded by memray.

## Retention/object-graph tools

These answer "*why* is this object still alive":

- [objgraph](https://github.com/mgedmin/objgraph) -- maintained (slow
cadence). In-process, `gc.get_referrers`-based backref chains, graphviz
output. Requires running code inside the target process; no snapshot
artifact.
- [guppy3 / heapy](https://github.com/zhuyifei1999/guppy3) -- actively
maintained, supports 3.10-3.14. In-process heap analysis with its own C
traversal; powerful, but analysis competes with the target process for
memory.
- [pympler](https://github.com/pympler/pympler) -- sporadically maintained.
In-process summaries plus class lifetime tracking.
- [meliae](https://pypi.org/project/meliae/) -- dump-to-JSON then offline
analysis; the closest architectural cousin to objex. Effectively
unmaintained.
- [PyHeap](https://github.com/ivanyu/pyheap) -- GDB-injected dumper plus
offline analyzer with a browser UI. CPython 3.8-3.12, Linux only.
- [dump-heap](https://github.com/lhchavez/dump-heap) -- ptrace-injected
whole-heap dumper, snapshot-diff oriented. x86_64 Linux only.

## objex's niche

objex's distinctive combination, shared by no maintained tool:
**fork-and-dump (near-zero pause for the target), a portable single-file
SQLite artifact, offline SQL-queryable analysis, and path-to-root attribution
against module/frame/thread roots with human-legible labels** (`.attr`,
`[i]`, `@key`, `.locals['x']`).

Notably, the gc-based competitors (objgraph, heapy) traverse via
`tp_traverse` and therefore see coroutines/iterators/tracebacks natively;
objex's labeled-edge scraping is its UX advantage and was historically its
completeness liability. The hybrid traversal (named labels where known,
`gc.get_referents`/`tp_traverse` fallback labeled `<gc>` everywhere else)
closes the completeness gap while keeping the label advantage, and the
gc-coverage audit in `tests/test_gc_coverage.py` keeps it closed across
CPython upgrades.

## Positioning

| Tool | Retention paths | Offline artifact | Prod-safe capture | Native allocs | Maintained |
|---|---|---|---|---|---|
| tracemalloc | no | snapshots (in-process) | overhead while tracing | no | stdlib |
| memray | no | yes (capture file) | yes (<5% overhead) | yes | yes |
| memory_profiler | no | no | no | no | no |
| objgraph | yes (in-process) | no | no (runs in target) | no | yes |
| guppy3/heapy | yes (in-process) | partial | no (runs in target) | no | yes |
| pympler | partial | no | no (runs in target) | no | sporadic |
| meliae | partial | yes (JSON dump) | dump pause | no | no |
| PyHeap | yes (offline) | yes | GDB stop-the-world | no | yes |
| dump-heap | diff-oriented | yes | ptrace pause | no | yes |
| **objex** | **yes (offline, labeled)** | **yes (single SQLite file)** | **yes (fork-and-dump)** | no | yes |

Rule of thumb: reach for memray/tracemalloc when the question is "what code
path is allocating all this memory"; reach for objex when the question is
"this object count/RSS keeps growing -- what is still holding these objects,
and from which module/frame root".
78 changes: 77 additions & 1 deletion objex/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,26 @@
from . import web


_COMMANDS = {'explore', 'make-analysis-db', 'web'}
_COMMANDS = {'explore', 'make-analysis-db', 'web', 'q'}

_Q_ENDPOINTS = (
'summary', 'object', 'random', 'referents', 'referrers', 'type-search',
'top-types', 'largest-objects', 'root-summary', 'path-to-module',
'path-to-frame', 'go', 'marks', 'mark', 'sql')


def build_parser():
parser = argparse.ArgumentParser(
prog='python -m objex',
description='Export and explore Python object graphs stored in SQLite databases.',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
'agent workflow example:\n'
' python -m objex q analysis.db summary\n'
' python -m objex q analysis.db root-summary sample_size=200\n'
' python -m objex q analysis.db go q=SomeType\n'
' python -m objex q analysis.db path-to-module id=4211\n'
),
)
subparsers = parser.add_subparsers(dest='command')

Expand All @@ -35,6 +48,24 @@ def build_parser():
web_parser.add_argument('analysis_db', help='Path to an objex analysis database.')
web_parser.add_argument('--host', default='127.0.0.1', help='Host interface to bind. Default: 127.0.0.1')
web_parser.add_argument('--port', type=int, default=8000, help='Port to bind. Default: 8000')

q_parser = subparsers.add_parser(
'q',
help='Run a one-shot JSON query against an analysis database (agent/scripting interface).',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
'endpoints: {}\n'
'examples:\n'
' python -m objex q analysis.db summary\n'
' python -m objex q analysis.db path-to-module id=4211 limit=5\n'
' python -m objex q analysis.db sql "SELECT name, count(*) FROM pytype GROUP BY name"\n'
).format(', '.join(_Q_ENDPOINTS)),
)
q_parser.add_argument('analysis_db', help='Path to an objex analysis database.')
q_parser.add_argument('endpoint', help='Query endpoint name (see below).')
q_parser.add_argument(
'params', nargs='*',
help='key=value query parameters, or one SQL string for the sql endpoint.')
return parser


Expand All @@ -54,6 +85,9 @@ def main(argv=None):
explorer.make_analysis_db(args.collection_db, args.analysis_db)
return 0

if args.command == 'q':
return _run_query(args)

if args.command == 'web':
server = web.serve(args.analysis_db, host=args.host, port=args.port)
actual_host, actual_port = server.server_address[:2]
Expand Down Expand Up @@ -90,5 +124,47 @@ def main(argv=None):
return 0


def _run_query(args):
import json
import sqlite3
import sys
from urllib.parse import urlencode

if args.endpoint == 'sql':
if len(args.params) != 1:
print(json.dumps({'error': "the sql endpoint takes exactly one SQL string"}))
return 1
# read-only enforced at the sqlite level; this is the escape hatch,
# never exposed over HTTP (schema documented in objex/schema.py)
conn = sqlite3.connect('file:{}?mode=ro'.format(args.analysis_db), uri=True)
conn.text_factory = str
try:
cursor = conn.execute(args.params[0])
columns = [d[0] for d in cursor.description or []]
rows = [list(row) for row in cursor.fetchall()]
except sqlite3.Error as exc:
print(json.dumps({'error': str(exc)}))
return 1
finally:
conn.close()
print(json.dumps({'columns': columns, 'rows': rows}, sort_keys=True, default=repr))
return 0

params = []
for token in args.params:
key, sep, value = token.partition('=')
if not sep:
print(json.dumps({'error': 'expected key=value parameter, got {!r}'.format(token)}))
return 1
params.append((key, value))
path = '/api/{}'.format(args.endpoint)
if params:
path = '{}?{}'.format(path, urlencode(params))
status, _content_type, body = web.dispatch_request(args.analysis_db, path)
sys.stdout.write(body.decode('utf-8'))
sys.stdout.write('\n')
return 0 if status == 200 else 1


if __name__ == '__main__':
raise SystemExit(main())
Loading
Loading