Metadata-Version: 2.4
Name: funcsnap
Version: 0.0.1.dev3
Summary: Analyze Python functions and extract dependency source for reconstruction
Project-URL: Repository, https://github.com/kyrylo-gr/funcsnap
Author: funcsnap contributors
License-Expression: MIT
License-File: LICENSE
Keywords: ast,introspection,serialization,source
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: numpy>=1.26; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Requires-Dist: twine>=5; extra == 'dev'
Description-Content-Type: text/markdown

<div align="center">

[![PyPI](https://img.shields.io/pypi/v/funcsnap.svg)](https://pypi.org/project/funcsnap/)
![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)
[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-261230.svg)](https://github.com/astral-sh/ruff)
[![CI](https://github.com/kyrylo-gr/funcsnap/actions/workflows/ci.yml/badge.svg)](https://github.com/kyrylo-gr/funcsnap/actions/workflows/ci.yml)
[![CodeFactor](https://www.codefactor.io/repository/github/kyrylo-gr/funcsnap/badge/main)](https://www.codefactor.io/repository/github/kyrylo-gr/funcsnap/overview/main)
[![Download Stats](https://img.shields.io/pypi/dm/funcsnap)](https://pypistats.org/packages/funcsnap)
[![Documentation](https://img.shields.io/badge/docs-blue)](https://github.com/kyrylo-gr/funcsnap#readme)

</div>

# funcsnap

**funcsnap** takes a live Python function or method, walks the objects it closes over, and collects enough information to **reconstruct** a self-contained Python source string you can `exec()` or save as a single file.

You get two complementary outputs:

1. **`get_source_functions(func, ...)`** — a flat dictionary keyed by stable IDs, with source text, imports, and dependency edges (`external_vars`). Use this when you want to inspect the graph, filter it, or emit your own format.
2. **`reconstruct(results)`** — one concatenated module string (imports, globals, definitions, optional shims) in dependency order, ready for `exec()`.

By default, anything installed in the **interpreter environment** (stdlib, `site-packages`, venv layout) is **not** expanded into separate definitions; those dependencies remain **references** so you do not accidentally inline large third-party trees.

## Install

```bash
pip install funcsnap
```

## Quick start

```python
from funcsnap import get_source_functions, reconstruct

results = get_source_functions(your_func)
code = reconstruct(results)
namespace = {}
exec(code, namespace)
# Call the emitted names from `namespace` (usually the function’s short name).
```

Public API: `get_source_functions`, `reconstruct` ([`funcsnap/__init__.py`](funcsnap/__init__.py)).

## `get_source_functions` — result shape

The return value is a **`dict[str, dict]`**: a **flat** map, not a nested tree. The “tree” is the graph you get by following each entry’s **`external_vars`** list.

### Keys (IDs)

- **Functions:** `{module}.{function_name}` (e.g. `mypkg.util.foo`)
- **Classes:** `{module}.{ClassName}`
- **Captured globals** (non-function, non-class): `{module}.{name}` with `type: "variable"`

### Entry fields

| Field | Used for | Meaning |
|-------|-----------|---------|
| `type` | all | `"function"`, `"class"`, or `"variable"` |
| `value` | all | Source text for functions/classes; `repr(value)` text for variables |
| `file` | function, class | Path to the defining `.py` file (when known) |
| `imports` | function, class | Top-level import lines from that file that bind names used in the snippet |
| `external_vars` | function, class | List of **other result keys** or **module names** this piece depends on |

Packages under the environment roots (see [Path filters](#path-filtering-include_dirs-exclude_dirs-and-the-default-environment-skip)) are usually **not** given their own keys; they still appear in `external_vars` (e.g. a `numpy` reference) so you know what is missing from the bundle.

### Worked example

Save as `demo.py` and run `python demo.py` (a real file is needed so source can be read reliably):

```python
from funcsnap import get_source_functions

SCALE = 10


def helper(x):
    return x * SCALE


def root(y):
    return helper(y) + 1


if __name__ == "__main__":
    results = get_source_functions(root)
    print(sorted(results.keys()))
```

You should see something like:

```text
['__main__.SCALE', '__main__.helper', '__main__.root']
```

One entry (the root function) looks like this (ellipsis added for readability):

```python
results["__main__.root"] == {
    "type": "function",
    "value": "def root(y):\n    return helper(y) + 1\n",
    "file": "/path/to/demo.py",
    "imports": [],
    "external_vars": ["__main__.helper"],
}
```

The captured constant:

```python
results["__main__.SCALE"] == {
    "type": "variable",
    "value": "10",  # repr(SCALE)
}
```

`helper` links to `SCALE` via its own `external_vars`; `root` links to `helper`. That list is what `reconstruct()` uses to order definitions.

### Optional: narrowing with `include_dirs`

If you only want to recurse into code under certain roots, pass absolute directories. The **entrypoint function you pass in is always recorded**; dependencies are only expanded when their defining file lies under one of the include paths (after the default environment skip).

```python
import tempfile
from funcsnap import get_source_functions

def root(y):
    return y + 1

# Empty directory: nothing under it matches dependency files → only the root remains.
with tempfile.TemporaryDirectory() as tmp:
    results = get_source_functions(root, include_dirs=[tmp])

assert sorted(results.keys()) == ["__main__.root"]
```

See [Path filtering](#path-filtering-include_dirs-exclude_dirs-and-the-default-environment-skip) for the full rules.

## Path filtering: `include_dirs`, `exclude_dirs`, and the default environment skip

Resolution uses [`FunctionAnalyzer._should_analyze`](funcsnap/save_source_function.py) in this order:

1. **Environment directories** — paths from `sysconfig.get_paths()`, `site.getsitepackages()`, and `site.getusersitepackages()`. If a dependency’s file path lies **under** one of these roots, funcsnap **does not recurse** into it (you do not pull in the stdlib or installed packages as inlined definitions). References can still appear in `external_vars`.
2. **`exclude_dirs`** — additional absolute subtrees to skip. Recursion into those files stops; **IDs from excluded code may still appear in `external_vars`**, so dependencies are not silently forgotten.
3. **`include_dirs`** — if not `None`, a file is analyzed only when it falls **under** one of these directories (after step 1). The root function you pass in is still stored.

Use **absolute paths**; funcsnap normalizes with `os.path.abspath`.

## `reconstruct` — single module string

`reconstruct(results)` turns the flat map into one string with labeled sections:

1. **Imports** — needed import lines gathered from all entries. Imports are **omitted** when every name they bind will already be defined in the bundle. **Relative imports are not emitted as-is** (they break under a bare `exec()` without package context); when needed, funcsnap may build **`types.SimpleNamespace` shims** so attribute access like `prim.clamp` still resolves after definitions exist.
2. **Variables** — `variable` entries become `name = <repr>` assignments.
3. **Definitions** — functions and classes, ordered by a **topological sort** on `external_vars` (Kahn’s algorithm), with a **cycle fallback** if the graph is not a strict DAG.
4. **Name collisions** — if the same **short** name (last segment of the key) appears for more than one full ID, definitions are emitted under prefixed names such as `_mylib_core_primitives__scale`, derived from the module path. A trailing **collision alias** block sets the bare `scale` to a **canonical** choice and exposes the others as `scale__<module_slug>`.
5. **Module shims** — emitted after definitions when relative-import shims need already-defined names.

**Safety / correctness:** these steps are **about making the bundle importable and consistent** (renames, import elision, avoiding broken relative imports). They are **not** a sandbox: never `exec()` untrusted code you did not audit.

Example continuation from [the worked example](#worked-example): `reconstruct(results)` can produce:

```python
# --- Variables ---
SCALE = 10

# --- Definitions ---
def helper(x):
    return x * SCALE

def root(y):
    return helper(y) + 1
```

You can `exec` that string, then call `namespace["root"](...)` (or write it to a `.py` file and run it).

## Limitations

- **Dynamic or opaque objects** (C extensions, unusual `inspect` cases) may not yield usable source.
- **Relative imports** inside the original package need special handling; `reconstruct()` avoids emitting them verbatim and may use shims, but complex package layouts can still require manual fixes.
- The analyzer follows **static name use** in the source text plus runtime globals; highly dynamic patterns may be incomplete.

## Development

Clone the repo, create a virtual environment, then:

```bash
pip install -e ".[dev]"
ruff check .
pytest
```

Version strings are derived from Git tags ([hatch-vcs](https://github.com/ofek/hatch-vcs)). Without a repository or tags, builds use the fallback version from `pyproject.toml`.

### PyPI releases (maintainers)

1. Configure **trusted publishing** on [PyPI](https://pypi.org/manage/account/publishing/) for this repository and workflow `.github/workflows/publish.yml` (optional GitHub Environment `pypi`).
2. Merge your changes to `main`, then tag and push: `git tag v0.1.0 && git push origin v0.1.0` (use the next semantic version). The publish workflow runs on tags matching `v*`.

Ensure `[project.urls] Repository` in `pyproject.toml` points at [https://github.com/kyrylo-gr/funcsnap](https://github.com/kyrylo-gr/funcsnap) so PyPI metadata matches the project home.

## License

MIT — see [LICENSE](LICENSE).
