Metadata-Version: 2.4
Name: relimport
Version: 0.1.0
Summary: Relative-import bootstrap for project scripts
Author: Oleksandr Shekhovtsov
License-Expression: MIT
Project-URL: Homepage, https://gitlab.fel.cvut.cz/shekhole/relimport
Project-URL: Source, https://gitlab.fel.cvut.cz/shekhole/relimport
Keywords: import,relative-import,packaging,scripts
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# relimport — the relative-import hack

Let scripts that live *inside* a project import the project's components (and each
other) **relatively**, no matter which directory they are launched from — with a
single line at the top of the script:

```python
import relimport
```

## Why

I develop **projects**, not packages: a project has scripts inside it (including in
subdirs like `experiments/`) that need to import the project's own importable
components, and I like running modules directly to keep a couple of quick tests in
them. But a script started directly cannot do project-relative imports — Python does
not know where it sits in the package (`__package__`/`__name__` are missing), so
`from .foo import bar` fails. The usual fixes (`python -m` from *outside* the
project, or dumping everything on `PYTHONPATH`) are awkward and increase the risk of
importing the wrong same-named package.

`relimport` automates the old manual workaround: on import it figures out the
project package from the script's location on disk and patches the caller so
relative imports just work — from any working directory.

## Install

The repo root *is* the package `relimport`. Any of these makes `import relimport`
resolve to it:

```bash
# editable install from a local clone (recommended for development)
pip install -e /path/to/relimport

# or install straight from the repo
pip install git+ssh://git@gitlab.fel.cvut.cz/shekhole/relimport.git

# or, without installing, put the clone's PARENT directory on sys.path
export PYTHONPATH=/path/to/parent-of-relimport
```

The editable and git installs are self-contained: no assumption about where the
repo sits on disk. The `PYTHONPATH` form works because the repo directory is named
`relimport` and its parent is on the path — handy if you keep several such repos
side by side in one directory.

## Usage

Put `import relimport` at the **top** of an executable script, before any
project-relative import:

```python
import relimport            # patches __package__, sys.path; sets __run__

from .utils import helper   # project-relative import now works

if __name__ == "__main__":  # or: if __run__:
    helper()
```

When the module is the program entry point (`__name__ == "__main__"`), `relimport`:

1. **finds the project root** by walking up from the script's directory,
2. **derives the package name** from the root directory's name (validated),
3. sets the caller's **`__package__`** so relative imports resolve,
4. inserts the root's **parent** at the front of `sys.path` (if absent),
5. sets **`__run__ = True`**, and
6. prints one line to **stderr** describing what it inferred, e.g.

   ```
   relimport: package='relimport_demo' root=/path/to/relimport_demo (via .package)
   ```

It deliberately **does not** rewrite `__name__` (so the standard
`if __name__ == "__main__":` idiom keeps working) and **does not** `chdir`.

When the module is **imported normally** (not `__main__`), `relimport` does nothing
except set **`__run__ = False`** — a module may carry `import relimport` because it
is *also* runnable as a script, but when merely imported it stays inert.

### `proj_dir()` and `file_dir()`

Because `relimport` never changes the working directory, use these to read/write
relative to a stable location regardless of where the script was launched:

- **`relimport.proj_dir()`** → `pathlib.Path` of the project root of the running
  (`__main__`) script. Fixed for the whole run.
- **`relimport.file_dir()`** → `pathlib.Path` of the **calling** file's directory
  (an imported utility module gets *its own* directory).

```python
out = relimport.proj_dir() / "results" / "fig.pdf"
```

A utility that should act on the launch directory just uses `os.getcwd()`, which
`relimport` never touches.

## Project-root markers

Walking up from the script's directory, the **first** ancestor containing any of
these markers is the project root:

- `.git`
- `.package`
- `.vscode`

Each is matched with `Path.exists`, so **both files and directories count** — in
git submodules and linked worktrees `.git` is a *file* (a `gitdir:` pointer), not a
directory. The nearest marked ancestor wins, so dropping a `.package`/`.vscode` in a
subfolder intentionally scopes it as its own project. If no marker is found up to
the filesystem root, `relimport` raises a clear error.

## Valid package names

The root directory's name becomes the top-level package; a script in a subdirectory
gets the root name joined with the relative subpath, dotted (root `relimport_demo/`,
script `relimport_demo/test/test2.py` → `relimport_demo.test`). Every component must
be a valid Python
module name:

```python
ok = name.isidentifier() and not keyword.iskeyword(name)
```

So hyphens (`my-project`), leading digits (`2024-exp`), dots, and reserved words are
rejected with a clear error at import time. No silent sanitizing, no override (kept
simple). The root name is the usual culprit; rename it, or place a `.package` marker
at a validly-named level.

## The `__run__` vs `__main__` idioms

Two equivalent ways to guard "run only when executed as a script":

```python
if __name__ == "__main__":   # standard; relimport keeps __name__ unchanged
    ...

if __run__:                  # convenience flag relimport sets in every importer
    ...
```

`__run__` is `True` only in the entry script and `False` in imported modules. It is
provided for convenience and continuity with older scripts; once everything works
with the `__name__` idiom it may be dropped.

### Implementation note (mechanism)

To make `__run__` appear in **every** module that does `import relimport` — not just
the first importer — the patch must run on every import. A normal cached import does
not re-execute the body, and the self-delete trick from the design notes
(`del sys.modules["relimport"]`) does not work on modern CPython: after running a
module body, `importlib._load_unlocked` re-inserts it into `sys.modules`, so a
body-level delete both raises `KeyError` and would not persist. Instead `relimport`
installs a tiny one-time shim around `builtins.__import__`, which *is* invoked on
every `import relimport` (cached or not) and receives the importing module's globals
directly. The entry script is patched once; later importers only get `__run__`. If
`__run__` is dropped in favor of `if __name__ == "__main__":`, the shim can be
dropped too, reverting to plain caching.

## Example

`relimport_demo/` is a self-contained demo project. Its own `.package` marker makes
its package name `relimport_demo` — a distinct top-level name, so it cannot collide
with the `relimport` tool itself (nor, say, the stray `example.py` that some
packages such as `nvidia-ml-py` drop into `site-packages`). It uses no
`__init__.py`: the directories resolve as namespace packages.

```
relimport_demo/
  .package        # marker → project root; package = "relimport_demo"
  test1.py        # entry at the demo root        → package "relimport_demo"
  test/
    test2.py      # script in a subdirectory       → package "relimport_demo.test"
```

- `test1.py` does `import relimport` then `from .test import test2`.
- `test2.py` does `import relimport` then `from .. import test1`.

Each is runnable directly from **any** directory and imports the other (with
`relimport` installed, or its repo's parent on `PYTHONPATH`):

```bash
python relimport_demo/test1.py
python relimport_demo/test/test2.py
```

## Tests

`tests/test_examples.py` launches the example scripts as real command-line
processes from several working directories (project root, a subdir, and `/tmp`) and
asserts exit code 0, the `relimport:` stderr log, and the cross-import success
markers — proving launch-location independence.

```bash
python -m pytest tests/        # with relimport installed (e.g. pip install -e .)
```
