Skip to content

Commit

Permalink
handle dashed namespace names
Browse files Browse the repository at this point in the history
  • Loading branch information
carderne committed Aug 15, 2024
1 parent 5c0ce5b commit 49e4765
Show file tree
Hide file tree
Showing 11 changed files with 159 additions and 41 deletions.
142 changes: 126 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,125 @@
# 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:
una is a tool to make Python monorepos with 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 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.
- `modules`: a more novel approach with just a single pyproject.toml, arguably better DevX and doesn't require a Rye workspace.

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)

## Quickstart
This will give you a quick view of how this all works.
A `packages` style will be used by default, as it is probably more familiar to most.

```bash
rye init my_example
cd my_example
rye add --dev una

# this will create a bunch of folders and files, have a look at them!
rye run una create workspace

# why not have a look
tree
```

The let's create some internal and external dependencies:
```bash
# add an external library to example_lib
cd libs/example_lib
rye add urllib3
cd ../..

# and then depend on example_lib from example_app
echo "import my_example.example_lib" > apps/example_app/my_example/example_app/foo.py
```

But then how do we ensure that builds of example_app will include all of the required code and dependencies?
```bash
# this checks all imports and ensures they are added to
# [tool.una.libs] in the appropriate pyproject.toml
rye run una sync

# have a look at what happened
tail apps/example_app/pyproject.toml
```

Now you can build your app:
```bash
rye build --package example_app

# and see the result
ls dist/
```

And you can do whatever you want with that wheel!
What about stick it in a Dockerfile, have you ever seen such a simple one?
```Dockerfile
FROM python
COPY dist dist
RUN pip install dist/*.whl
```

## Installation
The CLI tool isn't strictly necessary, as all the stuff that lets the monorepo builds work is in the separate (and tiny) [hatch-una](plugins/hatch) package.
But you will likely struggle to manage your monorepo without the tool!

So you may as well install it:
```bash
pip install una
```

As for the build-time `hatch-una`, it will automatically be installed by build tools when it spots this in your `pyproject.toml` (this will be configured automatically by the CLI):
```toml
[build-system]
requires = ["hatchling", "hatch-una"]
build-backend = "hatchling.build"
```

## Usage
The CLI has a few commands and options, have a look:
```bash
rye run una --help

Usage: una [OPTIONS] COMMAND [ARGS]...

╭─ Options ───────────────────────────────────────────────────────────────╮
│ --help Show this message and exit. │
╰─────────────────────────────────────────────────────────────────────────╯
╭─ Commands ──────────────────────────────────────────────────────────────╮
│ create Commands for creating a workspace, apps, libs and projects. │
│ diff Shows changed int_deps compared to the latest git tag. │
│ info Info about the Una workspace. │
│ sync Update pyproject.toml with missing int_deps. │
╰─────────────────────────────────────────────────────────────────────────╯
```

## Pyright
With the `packages` style (the default), you'll need do Pyright type-checking on a per-package basis.

That is, you should add something like the config below to each `apps/*/pyproject.toml` and `libs/*/pyproject.toml`.

With the `modules` style this is not necessary, and you can just have one root Pyright (and Pytest!).

```toml
[tool.pyright]
venvPath = "../.."
venv = ".venv"
pythonVersion = "3.11"
strict = ["**/*.py"]
```

## Style: Packages
In this setup, we use Rye's built-in workspace support. The structure will look something like this:
```bash
Expand Down Expand Up @@ -44,7 +148,8 @@ In this setup, we use Rye's built-in workspace support. The structure will look
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.
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.
Expand All @@ -62,8 +167,10 @@ virtual = true
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:
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]
Expand All @@ -75,19 +182,17 @@ build-backend = "hatchling.build"
[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:
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 .
COPY dist 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`.
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
Expand All @@ -113,8 +218,13 @@ The structure looks like this:
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!
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.
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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ classifiers = [
]

dependencies = [
"dataclasses-json ~= 0.6.7",
"dataclasses-json ~= 0.6",
"rich ~= 13.1",
"tomlkit ~= 0.10",
"typer ~= 0.8",
Expand Down
5 changes: 3 additions & 2 deletions una/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@


def check_int_ext_deps(root: Path, ns: str, project: Proj, options: Options) -> bool:
name = project.name
name = config.sanitise_name(project.name)
int_dep_imports, ext_dep_imports = collect_all_imports(root, ns, project)
collected_libs = collect_known_aliases(project, options)
details = create_report(
Expand Down Expand Up @@ -151,7 +151,8 @@ def create_report(
lib_pkgs = {c for c in project.int_deps.libs}
int_dep_diff = imports_diff(int_dep_imports, app_pkgs, lib_pkgs)

style = config.get_style()
root = config.get_workspace_root()
style = config.get_style(root)
include_libs = style == Style.modules
ext_dep_diff = external_deps.calculate_diff(ext_dep_imports, third_party_libs, include_libs)

Expand Down
11 changes: 6 additions & 5 deletions una/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def info_command(
):
"""Info about the Una workspace."""
root = config.get_workspace_root()
ns = config.get_ns()
ns = config.get_ns(root)
options = Options(alias=str.split(alias, ",") if alias else [])

internal_deps.int_deps_from_projects(root, ns)
Expand Down Expand Up @@ -79,7 +79,7 @@ def sync_command(
):
"""Update pyproject.toml with missing int_deps."""
root = config.get_workspace_root()
ns = config.get_ns()
ns = config.get_ns(root)
projects = internal_deps.get_projects_data(root, ns)
options = Options(
quiet=quiet,
Expand All @@ -104,7 +104,7 @@ def lib_command(
):
"""Creates an Una lib."""
root = config.get_workspace_root()
style = config.get_style()
style = config.get_style(root)
files.create_app_or_lib(root, name, "lib", style)
console = Console(theme=defaults.una_theme)
console.print("Success!")
Expand All @@ -117,7 +117,7 @@ def app_command(
):
"""Creates an Una app."""
root = config.get_workspace_root()
style = config.get_style()
style = config.get_style(root)
files.create_app_or_lib(root, name, "app", style)
console = Console(theme=defaults.una_theme)
console.print("Success!")
Expand All @@ -130,7 +130,8 @@ def workspace_command(
):
"""Creates an Una workspace in the current directory."""
path = Path.cwd()
ns = config.get_ns()
root = config.get_workspace_root()
ns = config.get_ns(root)
files.create_workspace(path, ns, style)
console = Console(theme=defaults.una_theme)
console.print("Success!")
Expand Down
12 changes: 7 additions & 5 deletions una/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ def get_project_dependencies(data: Conf) -> ExtDeps:
return ExtDeps(items=items, source=defaults.pyproj)


def get_ns() -> str:
path = get_workspace_root()
return load_conf(path).project.name
def sanitise_name(name: str) -> str:
return name.replace("-", "_")


def get_style() -> Style:
path = get_workspace_root()
def get_ns(path: Path) -> str:
return sanitise_name(load_conf(path).project.name)


def get_style(path: Path) -> Style:
return load_conf(path).tool.una.style


Expand Down
1 change: 1 addition & 0 deletions una/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
authors = []
dependencies = []
requires-python = "{python_version}"
dynamic = ["una"] # needed for hatch-una metadata hook to work
[build-system]
requires = ["hatchling", "hatch-una"]
Expand Down
2 changes: 1 addition & 1 deletion una/differ.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def calc_diff(tag_name: str | None, only_int_deps: bool):
print("No tags found in repository.")
return

ns = config.get_ns()
ns = config.get_ns(root)
files = get_files(tag)
apps_paths = get_changed_apps(root, files, ns)
libs_paths = get_changed_libs(root, files, ns)
Expand Down
8 changes: 5 additions & 3 deletions una/external_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def external_deps_from_all(


def missing_libs(project: Proj, imports: dict[str, OrgImports], options: Options) -> bool:
name = project.name
name = config.sanitise_name(project.name)
deps = project.ext_deps
int_dep_imports = imports[name]
libs = distributions.known_aliases_and_sub_dependencies(deps, options.alias)
Expand Down Expand Up @@ -59,7 +59,8 @@ def extract_ext_dep_imports(all_imports: Imports, top_ns: str) -> Imports:


def _get_third_party_imports(root: Path, paths: set[Path]) -> Imports:
top_ns = config.get_ns()
root = config.get_workspace_root()
top_ns = config.get_ns(root)
all_imports = parse.fetch_all_imports(paths)
return extract_ext_dep_imports(all_imports, top_ns)

Expand Down Expand Up @@ -121,7 +122,8 @@ def print_missing_installed_libs(
third_party_libs: set[str],
project_name: str,
) -> bool:
style = config.get_style()
root = config.get_workspace_root()
style = config.get_style(root)
include_libs = style == Style.modules
diff = calculate_diff(int_dep_imports, third_party_libs, include_libs)
if not diff:
Expand Down
11 changes: 5 additions & 6 deletions una/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def is_int_dep_dir(p: Path) -> bool:


def get_libs_dirs(root: Path, top_dir: str, ns: str) -> list[Path]:
style = config.get_style()
style = config.get_style(root)
sub = "" if style == Style.packages else ns
lib_dir = root / top_dir / sub
if not lib_dir.exists():
Expand Down Expand Up @@ -109,7 +109,7 @@ def create_project(path: Path, name: str) -> None:
def create_package(path: Path, name: str, kind: Literal["app", "lib"]) -> None:
conf = config.load_conf(path)
python_version = conf.project.requires_python
ns = conf.project.name
ns = config.get_ns(path)

top_dir = defaults.apps_dir if kind == "app" else defaults.libs_dir
app_dir = create_dir(path, f"{top_dir}/{name}")
Expand All @@ -130,9 +130,7 @@ def create_package(path: Path, name: str, kind: Literal["app", "lib"]) -> None:


def create_module(path: Path, name: str, kind: Literal["app", "lib"]) -> None:
conf = config.load_conf(path)
ns = conf.project.name

ns = config.get_ns(path)
top_dir = defaults.apps_dir if kind == "app" else defaults.libs_dir
code_dir = create_dir(path, f"{top_dir}/{ns}/{name}")
create_file(code_dir, "__init__.py")
Expand All @@ -151,7 +149,8 @@ def create_app_or_lib(path: Path, name: str, kind: DepKind, style: Style) -> Non


def get_project_roots(root: Path) -> list[Path]:
style = config.get_style()
ws_root = config.get_workspace_root()
style = config.get_style(ws_root)
prefix = "projects" if style == Style.modules else "apps"
return sorted(root.glob(f"{prefix}/*/"))

Expand Down
3 changes: 2 additions & 1 deletion una/internal_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ def get_project_int_deps(

def get_int_deps_in_projects(root: Path, libs_paths: list[str], apps_paths: list[str], namespace: str) -> list[Proj]:
packages = files.get_projects(root)
style = config.get_style()
ws_root = config.get_workspace_root()
style = config.get_style(ws_root)
add_self = style == Style.packages
res = [
Proj(
Expand Down
3 changes: 2 additions & 1 deletion una/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ def generate_updated_project(conf: Conf, packages: list[Include]) -> str | None:


def to_packages(ns: str, diff: Diff) -> list[Include]:
style = config.get_style()
root = config.get_workspace_root()
style = config.get_style(root)
apps_path = "../../apps"
libs_path = "../../libs"
a = [to_package(ns, b, apps_path, style) for b in diff.apps]
Expand Down

0 comments on commit 49e4765

Please sign in to comment.