Fix Python Relative Import Errors with -m, Package Entry Points, and a Linux Launcher
On this page
After splitting a Python script into a package, python3 triage_demo/cli.py fails with ImportError: attempted relative import with no known parent package. The files exist, but the launch method has not established the package context; installing another dependency is not necessarily the answer.
This beginner-to-intermediate tutorial reproduces the error with a text-line counter, switches to a python3 -m entry point, and adds a Linux launcher with a stable working directory. Tests cover a different starting directory, filenames with spaces, and error exits. No third-party packages or system configuration changes are needed.
1. Method: distinguish file and module entry points
The Python tutorial on intra-package references explains how dotted imports relate to the current package. Here, from .helpers import count_lines refers to helpers in the same package, not a file in the current working directory.
python3 triage_demo/cli.pyruns a file directly. In this example, no parent package is available for the relative import.python3 -m triage_demo.clistarts by module name, preserving thetriage_demopackage context.python3 -m triage_demoruns the package’s__main__.py, providing a shared command entry point.
The official -m documentation specifies a module name without .py or path separators. This tutorial runs an uninstalled source tree without a global PYTHONPATH or business-code edits to sys.path.
2. Prepare the environment and a minimal package
Use an existing Python 3.9 or newer interpreter and Linux sh, bash, or zsh. The examples were verified with Python 3.11.4. Keep all files in a new demo directory; choose another directory name when repeating the exercise.
- Run these commands in the same terminal:
python3 --version
python3 --help
mkdir relative-import-demo
cd relative-import-demo
demo_root="$PWD"
mkdir triage_demo data
: > triage_demo/__init__.py
printf 'alpha\nbeta\ngamma\n' > data/items.txt
- Save this as
triage_demo/helpers.py. It counts text lines, including blank lines, rather than CSV records:
from pathlib import Path
def count_lines(filename):
with Path(filename).open(encoding="utf-8") as source:
return sum(1 for _ in source)
- Save this as
triage_demo/cli.py:
import argparse
import sys
from .helpers import count_lines
def main():
parser = argparse.ArgumentParser(description="Count UTF-8 text lines.")
parser.add_argument("input", help="text file to read")
args = parser.parse_args()
try:
lines = count_lines(args.input)
except (OSError, UnicodeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(f"lines={lines}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
The empty __init__.py makes this example a regular package. main() handles arguments; read errors go to stderr with exit code 1, while argparse returns 2 for a missing argument. For multiple operations, see argparse subcommands and parameter validation.
3. Reproduce the error, then fix the launch with -m
- Stay in
relative-import-demoand deliberately run the file directly:
if python3 triage_demo/cli.py data/items.txt; then
printf 'Unexpected success: inspect the package and environment\n'
else
printf 'Expected failure: direct file execution lost package context\n'
fi
The traceback should contain:
ImportError: attempted relative import with no known parent package
The if handles this expected failure so a shell using set -e does not stop early. Adding if __name__ == "__main__" alone does not restore package context: the example already has that guard and still fails during import.
- Keep the import unchanged and launch by module name instead:
python3 -m triage_demo.cli --help
python3 -m triage_demo.cli data/items.txt
The first command prints help; the second prints lines=3 and exits 0. Inspect the module name and parent package during a normal import with:
python3 -c 'import triage_demo.cli as m; print(m.__name__); print(m.__package__)'
Expect triage_demo.cli and triage_demo, respectively. Avoid changing the import to bare from helpers import ... just to accommodate one launch method; that changes module identity and lookup location.
4. Add a package entry point and a stable Linux launcher
- Save this as
triage_demo/__main__.py. It delegates to the existingmain()rather than copying business logic:
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
The Python package entry-point documentation describes __main__.py. This example includes an entry guard so importing this file also avoids parsing arguments or starting a job.
python3 -m triage_demo data/items.txt
Expect lines=3 again. -m does not discover projects anywhere on disk. This package is uninstalled, so run from its parent directory; the same command elsewhere can fail to find triage_demo.
- Save
launch.shin the demo’s root directory:
#!/bin/sh
set -eu
project_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd -- "$project_dir"
exec "${PYTHON:-python3}" -m triage_demo "$@"
The launcher enters its own directory and then executes the module. "$@" preserves argument boundaries, and exec passes Python’s exit status to the caller. Set PYTHON to the absolute path of an existing interpreter; otherwise the launcher uses python3 from PATH. This variable is a single executable path, not a command string containing extra flags.
- Invoke it from another working directory:
chmod u+x launch.sh
(
cd /
sh "$demo_root/launch.sh" data/items.txt
)
Expect lines=3. Parentheses keep cd / inside a subshell. The launcher interprets relative input paths from the project root; pass an absolute path for an input in the caller’s directory. Run only trusted projects, do not invoke this example through a symlink, and do not treat it as an installer for every package layout.
Real scheduled jobs also need a stable interpreter and environment; see troubleshooting Linux job paths and environment. This tutorial only demonstrates launching and does not change a scheduler.
5. Automatically verify success and failure branches
Save this as verify_demo.py beside launch.sh. It uses the package created above, puts test inputs in its own temporary directory, and cleans up afterward without deleting your demo directory.
from pathlib import Path
import os
import subprocess
import sys
import tempfile
root = Path(__file__).resolve().parent
env = os.environ.copy()
for key in list(env):
if key.startswith("PYTHON"):
del env[key]
env["PYTHON"] = sys.executable
def run(args, cwd):
return subprocess.run(
args, cwd=cwd, env=env, capture_output=True,
text=True, timeout=10, check=False,
)
with tempfile.TemporaryDirectory(prefix="module-entry-test-") as tmp:
outside = Path(tmp)
source = outside / "input with spaces.txt"
source.write_text("alpha\n\ngamma\n", encoding="utf-8")
path = str(source)
direct = run([sys.executable, "triage_demo/cli.py", path], root)
assert direct.returncode == 1 and not direct.stdout, direct
assert "attempted relative import" in direct.stderr, direct
for module in ["triage_demo.cli", "triage_demo"]:
good = run([sys.executable, "-m", module, path], root)
assert (good.returncode, good.stdout, good.stderr) == (0, "lines=3\n", ""), good
foreign = run([sys.executable, "-m", "triage_demo", path], outside)
assert foreign.returncode != 0 and "No module named triage_demo" in foreign.stderr, foreign
wrapped = run(["sh", str(root / "launch.sh"), path], outside)
assert (wrapped.returncode, wrapped.stdout, wrapped.stderr) == (0, "lines=3\n", ""), wrapped
help_result = run([sys.executable, "-m", "triage_demo", "--help"], root)
assert help_result.returncode == 0 and "Count UTF-8 text lines." in help_result.stdout, help_result
no_arg = run([sys.executable, "-m", "triage_demo"], root)
assert no_arg.returncode == 2 and "required" in no_arg.stderr, no_arg
missing = run([sys.executable, "-m", "triage_demo", str(outside / "missing.txt")], root)
assert missing.returncode == 1 and missing.stderr.startswith("ERROR:"), missing
bad = outside / "bad.txt"
bad.write_bytes(b"\xff")
encoding = run([sys.executable, "-m", "triage_demo", str(bad)], root)
assert encoding.returncode == 1 and "ERROR:" in encoding.stderr, encoding
imported = run([sys.executable, "-c", "import triage_demo.cli; import triage_demo.__main__; print('import OK')"], root)
assert (imported.returncode, imported.stdout, imported.stderr) == (0, "import OK\n", ""), imported
print("OK: direct failure, module entry, package entry, foreign cwd, launcher, help, errors, safe import")
python3 verify_demo.py
Expected output:
OK: direct failure, module entry, package entry, foreign cwd, launcher, help, errors, safe import
The tests check direct-file failure, both module entry points, failure from an unrelated directory, launcher success, a path with spaces, blank-line counting, help, missing arguments, missing files, invalid UTF-8, and safe imports. Child processes have a ten-second timeout. Inherited PYTHON* settings are removed, and the launcher is given the current interpreter to isolate external path settings.
6. Common errors and direct fixes
| Symptom | Action |
|---|---|
attempted relative import with no known parent package |
Run python3 -m triage_demo.cli from the project root instead of executing a package file directly. |
No module named triage_demo |
Confirm that the current directory contains triage_demo/, or call launch.sh by absolute path. For an installed project, check its interpreter environment. |
No module named triage_demo.__main__ |
Create the __main__.py from section 4 or run -m triage_demo.cli directly. |
attempted relative import beyond top-level package |
Check the number of dots and the actual package hierarchy; a different launch method cannot fix an incorrect package relationship. |
| The package loads, but the input file is missing | The launcher resolves relative inputs from the project root. Pass an absolute input path. |
| The module name is correct, but its source is wrong | Inspect local names and follow module shadowing and import-path troubleshooting. |
Do not install a same-named package globally or delete every __pycache__ directory to address one import error. Check the command, working directory, and full traceback before distinguishing an entry-point problem from shadowing or a dependency environment issue.
7. Summary
Relative imports need the right package context; a file’s existence does not make direct execution the correct entry point. Keep business code in modules, use -m with a thin __main__.py, and connect automation through a launcher with a stable working directory. Verify the original failure as well as successful output to catch jobs that work in a terminal but cannot find their package elsewhere.
- Author: yanzhi
- Original article: https://www.bobobk.com/en/python-relative-import-module-entry.html
- License: This work is licensed under CC BY-NC-ND 4.0. See the license for attribution, noncommercial use and no-derivatives terms.
Related articles
- Python argparse Subcommands: Required Operations, Bounded Options, and Dash-Prefixed Filenames
- Check Executable Paths with Python shutil.which Before Automation Runs
- Fix Python BrokenPipeError with head and Verify Pipeline Exit Codes
- Fix Linux Argument list too long with find and Python
- Fix Python Module Shadowing: Inspect Import Origins and Interpreter Paths