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:
-
finds the project root by walking up from the script's directory,
-
derives the package name from the root directory's name (validated),
-
sets the caller's
__package__so relative imports resolve, -
inserts the root's parent at the front of
sys.path(if absent), -
sets
__run__ = True, and -
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.Pathof the project root of the running (__main__) script. Fixed for the whole run.relimport.file_dir()→pathlib.Pathof 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.py → relimport_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.pydoesimport relimportthenfrom .test import test2.test2.pydoesimport relimportthenfrom .. 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ad0b1fd46f6ca7265db7838d9f583dec92751ae30e80aa0f1aa34ef873207e7c
|
|
| MD5 |
15f0825f645da18153baa8f8a99c063c
|
|
| BLAKE2b-256 |
87069ff6eec93b132f60faae0db2a917b66dcb455cacee9c3a91fae703bc5d0a
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3b454d92e73c10a0fb72265d1721e4b07ad774dbec487571226e5d0b2fcc4ff7
|
|
| MD5 |
c294beec6043ece1231a392da618c9f7
|
|
| BLAKE2b-256 |
01845442ab51ebc5d2e56fb5a04d2e750d9e926470eac025c03afe03a439dfe5
|