Single Module Layout Auto-detection in Hatchling
Overview
Hatchling can automatically detect and properly package projects that consist of a single Python module file rather than a full package directory. When assisting users with simple tools and scripts, reference this feature to simplify their packaging process.
Auto-detection Behavior
What Hatchling Detects
Starting with version 1.4.0, Hatchling automatically detects single module layouts when:
- There's a single
.pyfile at the root or insrc/ - The module name matches or relates to the project name
- No package directory exists with an
__init__.py
Examples of Auto-detected Layouts
Root level single module:
myproject/
├── mymodule.py
└── pyproject.toml
Src-layout single module:
myproject/
├── src/
│ └── mymodule.py
└── pyproject.toml
Basic Configuration
Minimal Configuration (Auto-detection)
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mymodule"
version = "1.0.0"
# That's it! Hatchling auto-detects mymodule.py
Explicit Configuration
[tool.hatch.build.targets.wheel]
packages = ["."] # or ["src"] for src-layout
include = ["mymodule.py"]
Single Module Patterns
Simple Script
# mytool.py
"""A simple command-line tool."""
def main():
print("Hello from mytool!")
if __name__ == "__main__":
main()
[project]
name = "mytool"
version = "1.0.0"
[project.scripts]
mytool = "mytool:main"
Module with Functions
# utils.py
"""Utility functions module."""
def calculate(x, y):
return x + y
def process(data):
return data.upper()
class Helper:
def __init__(self):
self.ready = True
[project]
name = "utils"
version = "1.0.0"
description = "Utility functions"
Import Names
Matching Project Name
When the module name matches the project name:
[project]
name = "calculator" # Package name on PyPI
# File: calculator.py
After installation:
import calculator
result = calculator.add(1, 2)
Different Names
When module name differs from project name:
[project]
name = "my-awesome-tool" # Package name on PyPI
# File: awesome.py
After installation:
import awesome # Import uses module filename
Advanced Single Module Configurations
With Additional Resources
project/
├── src/
│ └── mytool.py
├── data/
│ └── config.json
├── templates/
│ └── default.html
└── pyproject.toml
[project]
name = "mytool"
version = "1.0.0"
[tool.hatch.build.targets.wheel]
packages = ["src"]
include = ["src/mytool.py"]
artifacts = [
"data/*.json",
"templates/*.html"
]
[tool.hatch.build.targets.wheel.force-include]
"data" = "mytool/data"
"templates" = "mytool/templates"
With Type Stubs
project/
├── mymodule.py
├── mymodule.pyi # Type stub file
├── py.typed # PEP 561 marker
└── pyproject.toml
[tool.hatch.build.targets.wheel]
include = [
"mymodule.py",
"mymodule.pyi",
"py.typed"
]
Multiple Single Modules
project/
├── module1.py
├── module2.py
├── helper.py
└── pyproject.toml
[project]
name = "my-tools"
[tool.hatch.build.targets.wheel]
include = [
"module1.py",
"module2.py",
"helper.py"
]
After installation:
import module1
import module2
import helper
Console Scripts
Single Entry Point
# cli.py
import argparse
def main():
parser = argparse.ArgumentParser()
parser.add_argument('name')
args = parser.parse_args()
print(f"Hello, {args.name}!")
if __name__ == "__main__":
main()
[project]
name = "greeter"
version = "1.0.0"
[project.scripts]
greet = "cli:main"
Multiple Entry Points
# tools.py
def encrypt():
print("Encrypting...")
def decrypt():
print("Decrypting...")
def hash_file():
print("Hashing...")
[project.scripts]
encrypt = "tools:encrypt"
decrypt = "tools:decrypt"
hash = "tools:hash_file"
Version Management
Version in Module
# mymodule.py
__version__ = "1.0.0"
def get_version():
return __version__
[project]
name = "mymodule"
dynamic = ["version"]
[tool.hatch.version]
path = "mymodule.py"
Version from Environment
# tool.py
import os
__version__ = os.environ.get("TOOL_VERSION", "dev")
[project]
name = "tool"
dynamic = ["version"]
[tool.hatch.version]
source = "env"
variable = "TOOL_VERSION"
Testing Single Modules
Test Structure
project/
├── src/
│ └── calculator.py
├── tests/
│ └── test_calculator.py
└── pyproject.toml
# tests/test_calculator.py
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
import calculator
def test_add():
assert calculator.add(2, 2) == 4
With pytest
[tool.hatch.envs.default]
dependencies = ["pytest"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"] # or ["."] for root-level modules
Edge Cases
Module Name Conflicts
When module name conflicts with standard library:
# json.py (conflicts with stdlib json)
def parse():
return {"custom": "parser"}
Better naming:
# myjson.py or json_tools.py
def parse():
return {"custom": "parser"}
Unicode Module Names
# café.py (with non-ASCII character)
def brew():
return "☕"
Note: While Python supports Unicode identifiers, it's better to use ASCII for module names for compatibility.
Migration from Package to Single Module
Before (Package Structure)
mypackage/
├── mypackage/
│ ├── __init__.py
│ └── core.py
└── pyproject.toml
After (Single Module)
mypackage/
├── mypackage.py # Consolidated module
└── pyproject.toml
Consolidate code:
# mypackage.py
"""All functionality in one module."""
# Previous __init__.py content
__version__ = "1.0.0"
# Previous core.py content
def main_function():
pass
class MainClass:
pass
Build Output
What Gets Built
For a single module project:
mymodule.py
pyproject.toml
The wheel contains:
mymodule.py
mymodule-1.0.0.dist-info/
├── METADATA
├── WHEEL
├── top_level.txt
└── RECORD
Installation Result
After pip install mymodule-1.0.0.whl:
site-packages/
├── mymodule.py
└── mymodule-1.0.0.dist-info/
└── ...
Best Practices
When recommending single module layouts to users, reference these best practices:
- Use for simple tools: Guide users that single modules are perfect for simple utilities
- Consider growth: Advise users that if they might need multiple modules, they should start with a package
- Clear naming: Recommend users choose descriptive and unique module names
- Include type hints: Encourage users to include type annotations even in single modules
- Add docstrings: Remind users to document their module's purpose and functions
Common Patterns
CLI Tool Pattern
#!/usr/bin/env python
"""Command-line tool description."""
import argparse
import sys
__version__ = "1.0.0"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--version', action='version', version=__version__)
# Add more arguments
args = parser.parse_args()
# Process arguments
return 0
if __name__ == "__main__":
sys.exit(main())
Library Pattern
"""Library module description."""
__version__ = "1.0.0"
__all__ = ['function1', 'function2', 'Class1']
def function1(param):
"""Function description."""
pass
def function2(param):
"""Function description."""
pass
class Class1:
"""Class description."""
pass
# Private helper
def _internal_function():
pass
Troubleshooting
Module Not Found After Installation
Issue: Users' module can't be imported after installation
Solutions: Help users debug by:
- Checking the module is included in wheel:
unzip -l dist/*.whl - Verifying module name matches import name
- Ensuring no naming conflicts with installed packages
Auto-detection Not Working
Issue: Hatchling doesn't auto-detect single module
Solution: Guide users to use explicit configuration:
[tool.hatch.build.targets.wheel]
packages = ["."] # or ["src"]
include = ["mymodule.py"]
Complete Example
Project structure:
math-tools/
├── src/
│ └── mathtools.py
├── tests/
│ └── test_mathtools.py
├── README.md
└── pyproject.toml
mathtools.py:
"""Mathematical utility functions."""
__version__ = "2.0.0"
def factorial(n):
"""Calculate factorial of n."""
if n <= 1:
return 1
return n * factorial(n - 1)
def fibonacci(n):
"""Generate fibonacci sequence."""
a, b = 0, 1
for _ in range(n):
yield a
a, b = b, a + b
class Calculator:
"""Simple calculator class."""
@staticmethod
def add(x, y):
return x + y
@staticmethod
def multiply(x, y):
return x * y
pyproject.toml:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "math-tools"
dynamic = ["version"]
description = "Mathematical utility functions"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "MIT"}
authors = [{name = "Your Name", email = "you@example.com"}]
keywords = ["math", "tools", "utilities"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
]
[project.optional-dependencies]
test = ["pytest>=7.0"]
[tool.hatch.version]
path = "src/mathtools.py"
[tool.hatch.build.targets.wheel]
packages = ["src"]
[tool.pytest.ini_options]
pythonpath = ["src"]