Python tools often need to answer a deceptively simple question: is this imported object a package or an ordinary module?
That distinction matters to plugin loaders, documentation generators, test discovery systems, command-line frameworks, code indexers, and developer tools. A package can contain importable children. An ordinary module cannot be traversed in the same way.
Python 3.14 adds inspect.ispackage(), a small predicate that gives this question a standard-library name.
import inspect
import json
import pathlib
print(inspect.ispackage(json)) # True
print(inspect.ispackage(pathlib)) # FalseThe API is tiny. The surrounding import semantics are not.
Using inspect.ispackage() correctly requires understanding what Python means by a package, why __file__ is the wrong signal, how namespace packages fit in, and why detecting a package does not make recursively importing it safe.
A package is a module that can contain submodules
At runtime, both packages and ordinary modules are module objects.
import inspect
import json
import pathlib
assert inspect.ismodule(json)
assert inspect.ismodule(pathlib)inspect.ismodule() therefore cannot distinguish them.
A package has an additional role in the import system: it supplies a __path__ that tells import machinery where its children may be found.
Python 3.14 exposes that distinction directly:
import inspect
import json
import pathlib
assert inspect.ispackage(json)
assert not inspect.ispackage(pathlib)This makes intent clearer than scattering low-level attribute checks throughout application code.
The old check was usually __path__
Before Python 3.14, runtime code commonly used a check like this:
import types
def is_package(obj: object) -> bool:
return isinstance(obj, types.ModuleType) and hasattr(obj, "__path__")That is essentially the semantic distinction the import system uses: by definition, a module with __path__ is a package.
Python 3.14 lets application code say what it means instead:
import inspect
if inspect.ispackage(module):
...The benefit is mostly readability and standardization, not a new package model.
When a future maintainer sees inspect.ispackage(module), they do not have to remember why a __path__ attribute is significant.
Do not classify packages by __file__
It is tempting to assume a package is a directory with an __init__.py file and therefore classify imported objects by filesystem layout.
That model is incomplete.
Namespace packages can exist without an __init__.py. Importers can also load modules from locations that are not ordinary source directories.
This is therefore the wrong abstraction:
from pathlib import Path
def looks_like_package(module) -> bool:
filename = getattr(module, "__file__", None)
if filename is None:
return False
return Path(filename).name == "__init__.py"It couples runtime classification to one common storage representation.
If you already have an imported module object and want to know whether Python considers it a package, use the runtime predicate:
import inspect
def classify_module(module) -> str:
if inspect.ispackage(module):
return "package"
if inspect.ismodule(module):
return "module"
return "not a module"Namespace packages are an important reason to use import semantics
PEP 420 namespace packages allow one logical package to span multiple directories.
Such a package may not have a conventional __init__.py file at all. Its __path__ can represent multiple search locations.
Suppose two installed distributions contribute modules under the same namespace:
site-packages-a/
acme/
billing/
__init__.py
site-packages-b/
acme/
search/
__init__.pyThe imported acme object can still be a package even though there is no single acme/__init__.py defining it.
Code based on source-file naming can misclassify this arrangement. Code based on package semantics does not need to care which importer produced the module.
import importlib
import inspect
acme = importlib.import_module("acme")
if inspect.ispackage(acme):
print("acme can participate in submodule imports")That is a more robust level of abstraction for tooling.
Detection happens after import
inspect.ispackage() accepts an object. It does not discover a package name without importing it.
This distinction matters.
import importlib
import inspect
module = importlib.import_module("myapp.plugins")
print(inspect.ispackage(module))By the time the predicate runs, myapp.plugins has already been imported and its import-time code may already have executed.
Do not treat inspect.ispackage() as a safe preflight check for untrusted Python code.
If your real problem is discovering modules without executing them, work with import specifications or distribution metadata first and postpone imports until you have decided what is allowed to execute.
Package detection is not plugin validation
A plugin system might begin with package detection:
import inspect
def load_children(root):
if not inspect.ispackage(root):
raise TypeError(f"{root!r} is not a package")That check is useful, but it proves only one thing: the object is a package.
It does not prove that:
- the package came from a trusted distribution,
- its children are safe to import,
- its metadata is valid,
- it implements your plugin protocol,
- its import path is unique,
- or importing every child is free of side effects.
Keep structural classification separate from trust and interface validation.
A simple plugin-package boundary
A plugin loader can use inspect.ispackage() to fail early when its configuration points at an ordinary module.
from __future__ import annotations
import importlib
import inspect
from types import ModuleType
def import_plugin_root(name: str) -> ModuleType:
root = importlib.import_module(name)
if not inspect.ispackage(root):
raise ValueError(
f"plugin root {name!r} must be a package, "
"not an ordinary module"
)
return rootThis creates a clear contract: a configured plugin root must be capable of containing children.
The next step—discovering those children—is a separate operation with separate failure modes.
Discover children with package-aware APIs
Once you have a package, pkgutil.iter_modules() can enumerate modules visible through its package path.
from __future__ import annotations
import inspect
import pkgutil
from types import ModuleType
def child_names(package: ModuleType) -> list[str]:
if not inspect.ispackage(package):
raise TypeError("expected a package")
prefix = package.__name__ + "."
return [
info.name
for info in pkgutil.iter_modules(package.__path__, prefix)
]Notice the ordering of responsibilities:
- verify that the object is a package,
- use its package path for discovery,
- decide which discovered names are eligible,
- import only the names you intend to execute.
Do not collapse all four stages into one recursive import loop unless your application genuinely needs that behavior.
Discovery does not imply import
A common plugin-loader mistake is importing everything that can be found beneath a package.
for name in child_names(root):
importlib.import_module(name)This may be acceptable for a tightly controlled application package. It is a poor default for extensible environments.
Imports execute Python code. A discovered module can perform network requests, read environment variables, register signal handlers, create threads, modify global registries, or fail because an optional dependency is absent.
Prefer an explicit selection boundary:
ALLOWED = {
"myapp.plugins.csv",
"myapp.plugins.json",
}
for name in child_names(root):
if name in ALLOWED:
importlib.import_module(name)For third-party ecosystems, entry-point metadata is often a better discovery mechanism than blindly walking an import tree.
inspect.ispackage() is about loaded module objects
The predicate should not be used as a general-purpose test for strings, paths, or module specifications.
These calls are conceptually wrong:
inspect.ispackage("json")
inspect.ispackage("src/myapp")They return false because strings are not package module objects.
Keep the representation clear in your APIs.
from types import ModuleType
def inspect_loaded_module(module: ModuleType) -> None:
...If your input is a module name, import or inspect its specification deliberately. If your input is a filesystem path, use filesystem APIs. If your input is an installed distribution, use package metadata APIs.
One predicate should not be stretched across all of those layers.
__spec__ answers related but different questions
Imported modules commonly expose a __spec__ describing how they were loaded.
A specification can contain submodule_search_locations, which is relevant to package discovery. This is useful when you are operating at the import-spec layer.
But if you already have a live module and simply need a yes-or-no package predicate, inspect.ispackage() communicates the question more directly.
Choose the layer that matches the job:
installed distribution metadata
↓
import name / ModuleSpec
↓
loaded module object
↓
application plugin objectPackage detection on a live module belongs on the third layer.
Do not use it to infer facts that belong to the other layers.
Packages can customize their search path
A package’s __path__ is not merely a directory name.
Import machinery treats it as the search locations for submodules. Traditional packages can alter it, namespace packages can expose multiple locations, and custom importers can participate in module resolution.
This is another reason not to rewrite package semantics as:
Path(module.__file__).parentThat expression may happen to work for a conventional source package, but it discards the import system’s actual search model.
When traversing children, consume package.__path__ through import-aware APIs rather than guessing a parent directory.
A package may have no useful source file
Developer tools often want both package classification and source information.
Treat those as independent capabilities.
import inspect
def describe(module) -> dict[str, object]:
return {
"name": getattr(module, "__name__", None),
"package": inspect.ispackage(module),
"file": getattr(module, "__file__", None),
}Do not reject a package merely because __file__ is absent.
Likewise, the presence of a file does not by itself prove that a module is a package.
Tools should degrade gracefully when source locations are unavailable.
Import errors need context
A package walker that imports children should report which phase failed.
from __future__ import annotations
import importlib
def import_selected(names: list[str]):
loaded = []
for name in names:
try:
module = importlib.import_module(name)
except Exception as exc:
raise RuntimeError(
f"failed while importing plugin candidate {name!r}"
) from exc
loaded.append(module)
return loadedDo not convert every exception into “not a package.”
An import can fail because of syntax errors, missing transitive dependencies, runtime configuration, platform incompatibility, or arbitrary import-time application errors.
Package classification and import success are different dimensions.
Avoid catching BaseException around plugin imports
This is tempting:
try:
module = importlib.import_module(name)
except BaseException:
return NoneIt is usually too broad.
BaseException includes process-control exceptions such as KeyboardInterrupt and SystemExit. Swallowing them can make command-line tools difficult to stop and can hide deliberate termination.
Catch Exception when you intend to isolate ordinary plugin failures, then attach the plugin name and preserve the original exception as the cause.
For production plugin systems, decide explicitly whether one broken plugin should abort startup or be quarantined while other plugins continue.
Do not assume every package should be recursively walked
A package boundary does not promise that every descendant is public or importable in your current environment.
A tree can contain:
- implementation-only modules,
- platform-specific modules,
- optional integrations,
- generated modules,
- expensive imports,
- test helpers,
- compatibility shims,
- and modules that intentionally fail when optional dependencies are missing.
inspect.ispackage() tells you that children are meaningful in import semantics. It does not grant permission to enumerate and execute the entire tree.
Prefer explicit plugin metadata or a naming convention with a narrow scope.
Testing ordinary packages and modules
The core behavior is easy to test with standard-library examples, but application tests should focus on your own boundary.
import inspect
import json
import pathlib
def test_package_detection():
assert inspect.ispackage(json)
assert not inspect.ispackage(pathlib)For a plugin loader, create fixture packages instead of depending entirely on standard-library layout.
A useful test tree might contain:
tests/fixtures/
demo_plugins/
__init__.py
alpha.py
nested/
__init__.py
beta.py
ordinary_module.pyThen test both accepted and rejected roots.
Test namespace packages separately
If your application claims to support namespace packages, include one in the test suite.
Do not assume a regular package test covers it.
Create two temporary import roots that contribute to the same namespace, put different child packages in each, add both roots to the test import path, and verify that discovery sees the intended children.
This catches code that accidentally falls back to __file__, __init__.py, or one physical parent directory.
The exact temporary-directory setup depends on your test framework, but the invariant is simple: package logic should follow import semantics rather than one filesystem shape.
Test import-time failures
A robust discovery system should also have a candidate module that raises during import.
For example:
# broken_plugin.py
raise RuntimeError("simulated plugin initialization failure")Verify that your loader:
- identifies the failing module by name,
- preserves the original exception,
- follows the application’s fail-fast or quarantine policy,
- and does not misreport the failure as a package-classification problem.
Failure-path tests are more valuable than another happy-path predicate assertion.
Test duplicate and shadowed names
Import behavior depends on search-path ordering.
If multiple locations can provide the same module name, your discovery system should not pretend that names uniquely identify filesystem locations independently of the import machinery.
Test the environment your application supports:
root-a/myapp_plugins/example.py
root-b/myapp_plugins/example.pyThen make the expected precedence explicit.
For security-sensitive extension systems, consider constraining where plugins may originate instead of accepting arbitrary sys.path shadowing.
Compatibility with Python before 3.14
inspect.ispackage() was added in Python 3.14.
If your library supports older Python releases, keep the compatibility decision at one boundary:
from __future__ import annotations
import inspect
import types
def is_package(module: object) -> bool:
predicate = getattr(inspect, "ispackage", None)
if predicate is not None:
return predicate(module)
return isinstance(module, types.ModuleType) and hasattr(module, "__path__")Application code can then depend on is_package() without repeating version checks.
If your project requires Python 3.14 or newer, prefer the standard function directly.
Do not version-check when capability-checking is clearer
This works:
import sys
if sys.version_info >= (3, 14):
...But a reusable compatibility layer often benefits from checking the capability it needs:
predicate = getattr(inspect, "ispackage", None)That keeps the fallback tied to the API rather than to assumptions about interpreter versions.
Whichever style your project uses, centralize it and test both branches if both are supported.
Package identity is not distribution identity
One of the easiest mistakes in Python packaging tooling is treating an import package and an installed distribution as the same object.
They are not.
An installed distribution can provide multiple import packages. A namespace package can receive contributions from multiple distributions. Distribution names and import names can differ.
Therefore this conclusion is invalid:
inspect.ispackage(module) == True
therefore
there is exactly one installed distribution with the same nameUse importlib.metadata when you need installed-distribution metadata. Use inspect.ispackage() when you need to classify a loaded module object.
Package detection is not a security boundary
A malicious or compromised package is still a package.
This code does not establish trust:
if inspect.ispackage(candidate):
run_plugin(candidate)The predicate says nothing about provenance, signatures, permissions, sandboxing, or authorization.
If plugins can come from users or third parties, define a separate trust model. Depending on the application, that might include an allowlist of distributions, isolated processes, restricted credentials, explicit installation workflows, or administrative approval.
Python imports execute code with the privileges of the Python process. Package classification does not change that fact.
Prefer narrow APIs over introspection everywhere
inspect.ispackage() is useful at infrastructure boundaries, but most business logic should not need to ask whether arbitrary objects are packages.
A plugin manager can normalize discovery once:
class PluginRegistry:
def __init__(self, plugins):
self._plugins = tuple(plugins)
def all(self):
return self._pluginsDownstream code can work with validated plugin objects instead of repeatedly inspecting import structure.
This keeps Python’s import model at the edge of the system rather than leaking it into unrelated application code.
A practical discovery design
For a controlled plugin namespace, a maintainable design looks like this:
from __future__ import annotations
import importlib
import inspect
import pkgutil
from types import ModuleType
def load_plugin_package(root_name: str) -> ModuleType:
root = importlib.import_module(root_name)
if not inspect.ispackage(root):
raise TypeError(f"{root_name!r} is not a package")
return root
def discover_direct_children(root: ModuleType) -> list[str]:
if not inspect.ispackage(root):
raise TypeError("root must be a package")
prefix = root.__name__ + "."
return sorted(
info.name
for info in pkgutil.iter_modules(root.__path__, prefix)
)
def import_allowed(names: list[str], allowed: set[str]) -> list[ModuleType]:
modules = []
for name in names:
if name not in allowed:
continue
modules.append(importlib.import_module(name))
return modulesEach function has one job.
The first validates the configured root. The second discovers direct children without pretending they are all approved. The third applies policy before importing candidates.
That separation makes testing and security review much easier than a recursive “find and import everything” helper.
When inspect.ispackage() is the right tool
Use it when all of these are true:
- you already have a live Python object,
- you need to distinguish packages from ordinary modules,
- package semantics matter to the next operation,
- and Python 3.14 is available or you have a compatibility wrapper.
It is especially natural in import tooling, plugin infrastructure, interactive inspectors, documentation tools, and test discovery code.
Do not use it when your real input is a path, a distribution name, or an unimported module name and a lower-level discovery API can answer the question without executing code.
The small predicate encodes a useful boundary
inspect.ispackage() is not one of Python 3.14’s largest features. It is valuable because it gives a common import-system concept a direct, readable predicate.
Instead of asking whether a module happens to have a particular source filename or manually checking import attributes everywhere, code can state the question plainly:
if inspect.ispackage(module):
discover_children(module)The important engineering work happens around that line.
Keep discovery separate from import. Support namespace packages if you claim to. Do not infer distribution identity from package identity. Preserve import failures accurately. Treat plugin execution as a trust decision rather than a type check.
With those boundaries in place, inspect.ispackage() becomes exactly what a good introspection API should be: a small primitive that makes larger tooling easier to read and harder to misunderstand.