Skip to main content

Relative-import bootstrap for project scripts

Project description

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:

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:

# 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:

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).
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.pyrelimport_demo.test). Every component must be a valid Python module name:

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":

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):

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.

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

relimport-0.1.0.tar.gz (8.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

relimport-0.1.0-py3-none-any.whl (8.0 kB view details)

Uploaded Python 3

File details

Details for the file relimport-0.1.0.tar.gz.

File metadata

  • Download URL: relimport-0.1.0.tar.gz
  • Upload date:
  • Size: 8.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.24 {"installer":{"name":"uv","version":"0.9.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for relimport-0.1.0.tar.gz
Algorithm Hash digest
SHA256 ad0b1fd46f6ca7265db7838d9f583dec92751ae30e80aa0f1aa34ef873207e7c
MD5 15f0825f645da18153baa8f8a99c063c
BLAKE2b-256 87069ff6eec93b132f60faae0db2a917b66dcb455cacee9c3a91fae703bc5d0a

See more details on using hashes here.

File details

Details for the file relimport-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: relimport-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 8.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.24 {"installer":{"name":"uv","version":"0.9.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for relimport-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3b454d92e73c10a0fb72265d1721e4b07ad774dbec487571226e5d0b2fcc4ff7
MD5 c294beec6043ece1231a392da618c9f7
BLAKE2b-256 01845442ab51ebc5d2e56fb5a04d2e750d9e926470eac025c03afe03a439dfe5

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page