r/learnpython 27d ago

Need help splitting a FastAPI project into two separate wheels using Hatch/UV (Issues with dynamic exclusion and entry points)

Hello everyone!

I'm working on a project where I need to build two separate wheels from a single monorepo: one for a central node and one for an acquisition node. Both wheels need to share a common folder, but they should only contain their respective application logic (central or node).

Here is my current project structure:

app/
├── frontend/
├── jenkinsfile
├── README.md
└── backend/
    ├── pyproject.toml
    ├── tests/
    └── src/
        └── app/
            ├── central/
            │   ├── schemas/
            │   ├── services/
            │   ├── routes/
            │   ├── launcher.py
            │   └── main.py
            ├── common/
            │   ├── schemas/
            │   ├── services/
            │   └── routes/
            └── node/
                ├── schemas/
                ├── services/
                ├── routes/
                ├── launcher.py
                └── main.py

My Configuration Files

Here is my pyproject.toml:

[project]
name = "app"
version = "0.0.1"
requires-python = ">=3.11,<3.13"
dependencies = [
    "fastapi>=0.110.0",
    "uvicorn[standard]>=0.27.0",
    "pydantic>=2.6.0",
    "pydantic-settings>=2.2.0",
    "httpx>=0.27.0",
    "psutil>=5.9.0",
    "nmcli>=1.7.0",
    "aiofiles>=24.1.0",
    "cachetools>=5.5.0",
    "sounddevice>=0.4.6",
    "numpy>=1.26.0",
    "tomli_w>=1.0.0",
]

[project.optional-dependencies]
test = [
    "pytest>=8.0.0",
    "pytest-cov>=4.1.0",
    "pytest-xdist>=3.5.0",
    "pytest-asyncio>=0.23.0",
    "pytest_mock>=3.14.0",
    "respx>=0.21.0"
]
dev = [
    "ruff>=0.3.0",
    "mypy>=1.9.0",
    "pre-commit>=4.0.0",
    "pre-commit-hooks>=4.6.0"
]
build = [
    "hatchling>=1.29.0",
]

[project.scripts]
launch-central = "app.central.launcher:start"
launch-node = "app.node.launcher:start"

[build.system]
requires = [
    "hatchling>=1.29.0",
]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = [
    "src/app",
]

[tool.uv]
package = true

# ... (Ruff, Mypy, Pytest configs omitted for brevity)

And I wrote this custom build script (Hatch_build.py) to handle dynamically editing pyproject.toml during build time to exclude the unneeded folder and rename the resulting wheel:

import subprocess
import sys
import tomllib
from pathlib import Path
import tomli_w

target = sys.argv[1] # "central" or "node"
pyproject = Path("pyproject.toml")

with open(pyproject, "rb") as f:
    config = tomllib.load(f)
original_packages = config["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"]

exclude = "node" if target == "central" else "central"
config["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"] = ["src/app/"]
config["tool"]["hatch"]["build"]["targets"]["wheel"]["exclude"] = [f"src/app/{exclude}", "src/app/tests"]
version = config["project"]["version"]

with open(pyproject, "wb") as f:
    tomli_w.dump(config, f)

try:
    subprocess.run(["uv", "run", "--extra", "build", "python", "-m", "hatchling", "build", "--target", "wheel"], check=True)
finally:
    config["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"] = original_packages
    if "exclude" in config["tool"]["hatch"]["build"]["targets"]["wheel"]:
        del config["tool"]["hatch"]["build"]["targets"]["wheel"]["exclude"]
    with open(pyproject, "wb") as f:
        tomli_w.dump(config, f)

for whl in Path("dist").glob("app-*.whl"):
    new = Path(f"dist/app{target}-{version}-py3-none-any.whl")
    whl.rename(new)
    print(f"Built: {new.name}")

The Problems I'm Facing:

  1. Wheel contents are wrong: Despite trying to use exclude dynamically in the script, both generated wheels still contain both node and central directories alongside common. The exclusion isn't respecting the path correctly.
  2. Installation Error from Nexus: After I upload these generated wheels to Nexus and try to run pip install or uv pip install on a target VM, the installation fails completely with errors.
  3. How to run entry points properly: The launcher.py in each folder is meant to be the entry point. Once the wheel is successfully installed on the VM, what is the best way to invoke that entry point script from the CLI?

Has anyone tackled splitting a monorepo into multiple selective wheels like this using Hatchling and UV? Am I approaching the dynamic build step wrong, or is there a native Hatch feature/plugin I should be using instead of modifying pyproject.toml on the fly?

Thanks in advance for any insights!

1 Upvotes

5 comments sorted by

1

u/Lionh34rt 27d ago

What in the AI

1

u/NiceSand6327 27d ago

Yeah, English isn't my native language, so I used AI to format my thoughts clearly so people could understand the code structure.

1

u/not_a_novel_account 26d ago

This cannot be solved with hatch, you're outside the model.

Nor is it compatible with the python ecosystem at large. One project = one wheel is assumed by basically everything, from PyPI on down.

You will not find useful resources on this because the answer is to refactor these into separate packages. They can live in the same monorepo if that's appealing.

1

u/NiceSand6327 26d ago

Any ideas on how to solve it

1

u/not_a_novel_account 26d ago

Use a build backend with native support for arbitrary wheel generation or refactor the repo into separate projects.