-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
40 changed files
with
2,890 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
name: pr | ||
|
||
on: | ||
pull_request: | ||
types: [opened, reopened, synchronize] | ||
|
||
concurrency: | ||
group: pr-${{ github.event.number }} | ||
cancel-in-progress: true | ||
|
||
jobs: | ||
changes: | ||
runs-on: ubuntu-latest | ||
permissions: | ||
pull-requests: read | ||
outputs: | ||
python: ${{ steps.filter.outputs.python }} | ||
steps: | ||
- uses: dorny/paths-filter@v3 | ||
id: filter | ||
with: | ||
filters: | | ||
python: | ||
- '**/*.py' | ||
- 'pyproject.toml' | ||
test-python: | ||
runs-on: ubuntu-latest | ||
needs: changes | ||
if: needs.changes.outputs.python == 'true' | ||
steps: | ||
- uses: actions/checkout@v4 | ||
- uses: eifinger/setup-rye@v3 | ||
id: setup-rye | ||
with: | ||
version: '0.38.0' | ||
- run: rye pin 3.12.3 | ||
- name: Sync | ||
run: | | ||
rye sync | ||
if [[ $(git diff --stat requirements.lock) != '' ]]; then | ||
echo 'Rye lockfile not up-to-date' | ||
git diff requirements.lock | ||
exit 1 | ||
fi | ||
- run: rye fmt --check | ||
- run: rye lint | ||
- run: rye run check | ||
- run: rye run test | ||
- run: rye run check | ||
working-directory: plugins/hatch |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
name: release | ||
on: | ||
release: | ||
types: [published] | ||
jobs: | ||
publish-una: | ||
environment: release | ||
runs-on: ubuntu-latest | ||
permissions: | ||
id-token: write | ||
steps: | ||
- uses: actions/checkout@v4 | ||
- uses: eifinger/setup-rye@v3 | ||
id: setup-rye | ||
with: | ||
version: '0.38.0' | ||
- run: rye build | ||
- uses: pypa/gh-action-pypi-publish@release/v1 | ||
|
||
publish-hatch-una: | ||
environment: release | ||
runs-on: ubuntu-latest | ||
permissions: | ||
id-token: write | ||
steps: | ||
- uses: actions/checkout@v4 | ||
- uses: eifinger/setup-rye@v3 | ||
id: setup-rye | ||
with: | ||
version: '0.38.0' | ||
- run: rye build | ||
working-directory: plugins/hatch | ||
- uses: pypa/gh-action-pypi-publish@release/v1 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,3 +3,5 @@ __pycache__ | |
.mypy_cache | ||
dist | ||
.coverage | ||
.hypothesis | ||
.obsidian |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,120 @@ | ||
# mono | ||
# una | ||
**Warning: this is pre-alpha and probably doesn't work at all. You'll probably just get frustrated if you even try to use it.** | ||
|
||
una is a tool to make Python monorepos with Hatch and/or Rye easier. It is a CLI tool and a Hatch plugin that does the following things: | ||
1. Enable builds of individual apps or projects within a monorepo. | ||
2. Ensure that internal and external dependencies are correctly specified. | ||
|
||
una is inspired by [python-polylith](https://github.com/DavidVujic/python-polylith) and is based on that codebase. But I find the [Polylith](https://polylith.gitbook.io/polylith) architecture to be quite intimidating for many, so wanted to create a lighter touch alternative that doesn't require too much re-thinking. This project has very limited ambitions and doesn't try to do everything a proper build system such as [Bazel](https://bazel.build/) or [Pants](https://www.pantsbuild.org/) does. It just tries to make a simple monorepo build just about possible. | ||
|
||
una allows two directory structures or styles: | ||
- `packages`: this is the lightest-touch approach, that is just some extra build help on top of a Rye workspace. | ||
- modules: a more novel approach with just a single pyproject.toml, arguably better DevX and compatible with Rye or Hatch alone. | ||
|
||
Within this context, we use the following words frequently: | ||
- `lib`: a module or package that will be imported but not run. | ||
- `app`: a module or package that will be run but never imported. | ||
- `project`: a package with no code but only dependencies (only used in the `modules` style) | ||
|
||
## Style: Packages | ||
In this setup, we use Rye's built-in workspace support. The structure will look something like this: | ||
```bash | ||
. | ||
├── pyproject.toml | ||
├── requirements.lock | ||
├── apps | ||
│ └── server | ||
│ ├── pyproject.toml | ||
│ ├── your_ns | ||
│ │ └── server | ||
│ │ ├── __init__.py | ||
│ └── tests | ||
│ └── test_server.py | ||
└── libs | ||
└── mylib | ||
├── pyproject.toml | ||
├── your_ns | ||
│ └── mylib | ||
│ ├── __init__.py | ||
│ └── py.typed | ||
└── tests | ||
└── test_mylib.py | ||
``` | ||
|
||
This means: | ||
1. Each `app` or `lib` (collectively, internal dependencies) is it's own Python package with a `pyproject.toml`. | ||
2. You must specify the workspace members in `tool.rye.workspace.members`. | ||
3. Type-checking and testing should be done on a per-package level. That is, you should run `pyright` and `pytest` from `apps/server` or `libs/mylib`, _not_ from the root. | ||
|
||
In the example above, the only build artifact will be for `apps/server`. At build-time, una will do the following: | ||
1. Read the list of internal dependencies (more on this shortly) and inject them into the build. | ||
2. Read all externel requirements of those dependencies, and add them to the dependency table. | ||
|
||
You can then use the `una` CLI tool to ensure that all internal dependencies are kept in sync. What are the key steps? | ||
1. Use a Rye workspace: | ||
```toml | ||
# /pyproject.toml | ||
[tool.rye] | ||
managed = true | ||
virtual = true | ||
|
||
[tool.rye.workspace] | ||
members = ["apps/*", "libs/*"] | ||
``` | ||
|
||
2. Create your apps and your libs as you would, ensuring that app code is never imported. Ensure that you choose a good namespace and always use it in your package structures (check `your_ns` in the example structure above.) | ||
3. Add external dependencies to your libs and apps as normal. Then, to add an internal dependency to an app, we do the following in its pyproject.toml: | ||
```toml | ||
# /apps/server/pyproject.toml | ||
[build-system] | ||
requires = ["hatchling", "hatch-una"] | ||
build-backend = "hatchling.build" | ||
|
||
[tool.hatch.build.hooks.una-build] | ||
[tool.hatch.build.hooks.una-meta] | ||
[tool.una.libs] | ||
"../../libs/mylib/example/mylib" = "example/mylib" | ||
``` | ||
4. Then you can run `rye build --wheel` from that package directory and una will inject everything that is needed. Once you have your built `.whl` file, all you need in your Dockerfile is: | ||
```Dockerfile | ||
FROM python | ||
COPY dist . | ||
RUN pip install dist/*.whl | ||
``` | ||
### Historical note | ||
What is needed to get root-level pyright and pytest to work? | ||
1. Delete apps/lib pyproject.toml as they cause pyright/basedpyright to get lost. | ||
2. Add pythonpath to pytest settings. | ||
|
||
## Style: Modules | ||
This approach is inspired by [Polylith](https://davidvujic.github.io/python-polylith-docs/). You don't use a Rye workspace (and indeed this approach will work with just Hatch), and there's only a single `pyproject.toml`. | ||
|
||
The structure looks like this: | ||
```bash | ||
. | ||
├── pyproject.toml | ||
├── requirements.lock | ||
├── apps | ||
│ └── your_ns | ||
│ └── server | ||
│ ├── __init__.py | ||
│ └── test_server.py | ||
├── libs | ||
│ └── your_ns | ||
│ └── mylib | ||
│ ├── __init__.py | ||
│ ├── core.py | ||
│ └── test_core.py | ||
└── projects | ||
└── server | ||
└── pyproject.toml | ||
``` | ||
|
||
The key differences are as follows: | ||
1. `apps/` and `libs/` contain only pure Python code, structured into modules under a common namespace. | ||
2. Tests are colocated with Python code (this will be familiar to those coming from Go or Rust). | ||
3. Because `apps/` is just pure Python code, we need somewhere else to convert this into deployable artifacts (Docker images and the like). So we add `projects/` directory. This contains no code, just a pyproject.toml and whatever else is needed to deploy the built project. The pyproject will specify which internal dependencies are used in the project: exactly one app, and zero or more libs. | ||
4. It must also specify all external dependencies that are used, including the transitive dependencies of internal libs that it uses. But the una CLI will help with this! | ||
|
||
And there's one more benefit: | ||
5. You can run pyright and pytest from the root directory! This gives you a true monorepo benefit of having a single static analysis of the entire codebase. But don't worry, una will help you to only test the bits that are needed. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
# hatch-una | ||
|
||
This is the Hatch plugin for [una](https://github.com/carderne/una). | ||
|
||
Read the full README there. |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import tomllib | ||
from pathlib import Path | ||
from typing import Any | ||
|
||
from hatchling.builders.config import BuilderConfig | ||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface | ||
from hatchling.plugin import hookimpl | ||
|
||
|
||
class BuildConfig(BuilderConfig): | ||
pass | ||
|
||
|
||
class UnaHook(BuildHookInterface[BuildConfig]): | ||
PLUGIN_NAME = "una-build" | ||
|
||
def initialize(self, version: str, build_data: dict[str, Any]) -> None: | ||
print("una: Injecting internal dependencies") | ||
root = Path(self.root) | ||
with (root / "pyproject.toml").open("rb") as fp: | ||
conf = tomllib.load(fp) | ||
|
||
int_deps: dict[str, str] = conf["tool"]["una"]["libs"] | ||
found = {k: v for k, v in int_deps.items() if (root / k).exists()} | ||
if not int_deps or not found: | ||
return | ||
build_data["force_include"] = {**build_data["force_include"], **int_deps} | ||
|
||
|
||
@hookimpl | ||
def hatch_register_build_hook(): | ||
return UnaHook |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
import tomllib | ||
from pathlib import Path | ||
from typing import Any | ||
|
||
from hatchling.metadata.plugin.interface import MetadataHookInterface | ||
from hatchling.plugin import hookimpl | ||
|
||
|
||
def load_conf(path: Path) -> dict[str, Any]: | ||
with (path / "pyproject.toml").open("rb") as fp: | ||
return tomllib.load(fp) | ||
|
||
|
||
class UnaMetaHook(MetadataHookInterface): | ||
PLUGIN_NAME = "una-meta" | ||
|
||
def update(self, metadata: dict[str, Any]) -> None: | ||
root = Path(self.root) | ||
conf = load_conf(root) | ||
int_deps: dict[str, str] = conf["tool"]["una"]["libs"] | ||
|
||
project_deps: list[str] = metadata.get("dependencies", {}) | ||
project_deps = [d.strip().replace(" ", "") for d in project_deps] | ||
|
||
add_deps: list[str] = [] | ||
for dep_path in int_deps: | ||
dep_project_path = Path(dep_path).parents[1] | ||
try: | ||
dep_conf = load_conf(dep_project_path) | ||
except FileNotFoundError: | ||
continue | ||
dep_deps: list[str] = dep_conf["project"]["dependencies"] | ||
dep_deps = [d.strip().replace(" ", "") for d in dep_deps] | ||
add_deps.extend(dep_deps) | ||
|
||
all_deps = list(set(project_deps + add_deps)) | ||
print(all_deps) | ||
metadata["dependencies"] = all_deps | ||
|
||
|
||
@hookimpl | ||
def hatch_register_metadata_hook(): | ||
return UnaMetaHook |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
[tool.rye.scripts] | ||
check = "basedpyright" | ||
|
||
[project] | ||
name = "hatch-una" | ||
dynamic = ["version"] | ||
description = "Python monorepo tooling" | ||
authors = [ | ||
{ name = "Chris Arderne", email = "[email protected]" } | ||
] | ||
readme = "README.md" | ||
license = {text = "MIT License"} | ||
requires-python = ">= 3.11" | ||
keywords = ["rye", "monorepo", "build", "python"] | ||
|
||
classifiers = [ | ||
"Environment :: Console", | ||
"Intended Audience :: Developers", | ||
"License :: OSI Approved :: MIT License", | ||
"Operating System :: Unix", | ||
"Programming Language :: Python", | ||
"Programming Language :: Python :: 3.10", | ||
"Programming Language :: Python :: 3.11", | ||
"Programming Language :: Python :: 3.12", | ||
] | ||
|
||
dependencies = ["hatchling"] | ||
|
||
[project.urls] | ||
homepage = "https://github.com/carderne/una" | ||
repository = "https://github.com/carderne/una" | ||
|
||
[project.entry-points.hatch] | ||
una-build = "hatch_una.hatch_build" | ||
una-meta = "hatch_una.hatch_meta" | ||
|
||
[tool.rye] | ||
managed = true | ||
dev-dependencies = [] | ||
|
||
[build-system] | ||
requires = ["hatchling", "hatch-vcs"] | ||
build-backend = "hatchling.build" | ||
|
||
[tool.hatch.version] | ||
source = "vcs" | ||
raw-options = { root = "../.." } | ||
|
||
[tool.basedpyright] | ||
venvPath = "../.." | ||
venv = ".venv" | ||
pythonVersion = "3.11" | ||
strict = ["**/*.py"] | ||
reportUnnecessaryTypeIgnoreComment = true | ||
reportImplicitOverride = false | ||
reportUnusedCallResult = false | ||
enableTypeIgnoreComments = true |
Oops, something went wrong.