diff --git a/README.md b/README.md index 49b0d46..9df41ae 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ But I find the [Polylith](https://polylith.gitbook.io/polylith) architecture to 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. +- `packages`: this is the default style, 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 doesn't require a Rye workspace. Within this context, we use the following words frequently: @@ -18,6 +18,11 @@ Within this context, we use the following words frequently: - `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) +## Examples +You can see examples for each of the two styles here: +- [carderne/una-example-packages](https://github.com/carderne/una-example-packages) +- [carderne/una-example-modules](https://github.com/carderne/una-example-modules) + ## 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. diff --git a/plugins/hatch/hatch_una/hatch_build.py b/plugins/hatch/hatch_una/hatch_build.py index 2727ba4..b40cb2f 100644 --- a/plugins/hatch/hatch_una/hatch_build.py +++ b/plugins/hatch/hatch_una/hatch_build.py @@ -13,17 +13,40 @@ class UnaBuildHook(BuildHookInterface[BuilderConfig]): def initialize(self, version: str, build_data: dict[str, Any]) -> None: print("una: Injecting internal dependencies") - root = Path(self.root) - conf = util.load_conf(root) + path = Path(self.root) + conf = util.load_conf(path) + + root_path = path.parents[1] + extra_root_path = util.EXTRA_PYPROJ / "root" + if (root_path / util.PYPROJ).exists(): + use_root_path = root_path + elif (extra_root_path / util.PYPROJ).exists(): + use_root_path = extra_root_path + else: + raise ValueError("No root pyproject to determine workspace style") + root_conf = util.load_conf(use_root_path) + style: str = root_conf["tool"]["una"]["style"] int_deps: dict[str, str] = conf["tool"]["una"]["libs"] - found = [Path(k) for k in int_deps if (root / k).exists()] + found = [Path(k) for k in int_deps if (path / k).exists()] if not int_deps or not found: # should I raise here? return - add_pyproj = {str(f.parents[1] / util.PYPROJ): str(util.EXTRA_PYPROJ / f.name / util.PYPROJ) for f in found} - build_data["force_include"] = {**build_data["force_include"], **int_deps, **add_pyproj} + add_root_pyproj = {str(root_path / util.PYPROJ): str(util.EXTRA_PYPROJ / "root" / util.PYPROJ)} + if style == "packages": + add_packages_pyproj = { + str(f.parents[1] / util.PYPROJ): str(util.EXTRA_PYPROJ / f.name / util.PYPROJ) for f in found + } + else: + add_packages_pyproj = {} + + build_data["force_include"] = { + **build_data["force_include"], + **int_deps, + **add_root_pyproj, + **add_packages_pyproj, + } @hookimpl diff --git a/una/cli.py b/una/cli.py index f800c29..ad90bdb 100644 --- a/una/cli.py +++ b/una/cli.py @@ -130,6 +130,24 @@ def app_command( console.print(f"Created app {name}") +@create.command("project") +def project_command( + name: Annotated[str, Argument(help="Name of the projectlib.")], + from_app: Annotated[str, Argument(help="Name of the app the project will use.")], +): + """Creates an Una project.""" + root = config.get_workspace_root() + style = config.get_style(root) + console = Console(theme=defaults.una_theme) + if style == Style.packages: + console.print("You can't create projects in a Packages style workspace") + exit(1) + + files.create_project(root, name, "", from_app) + console.print("Success!") + console.print(f"Created project {name}") + + @create.command("workspace") def workspace_command( style: Annotated[Style, Option(help="Workspace style")] = "packages", # type:ignore[reportArgumentType] diff --git a/una/defaults.py b/una/defaults.py index 3bc0b1b..7e9e559 100644 --- a/una/defaults.py +++ b/una/defaults.py @@ -21,6 +21,8 @@ example_app = "printer" example_lib = "greeter" +example_import = "cowsay-python==1.0.2" + packages_pyproj = """\ [project] name = "{name}" @@ -48,6 +50,39 @@ [tool.una.libs] """ +projects_pyproj = """\ +[project] +name = "{name}" +version = "0.1.0" +description = "" +authors = [] +dependencies = [{dependencies}] +requires-python = "{python_version}" + +[build-system] +requires = ["hatchling", "hatch-una"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build] +packages = ["{ns}"] + +[tool.hatch.build.hooks.una-build] + +[tool.una.libs] +""" + +example_packages_style_app_deps = """\ +"../../libs/{lib_name}/{ns}/{lib_name}" = "{ns}/{lib_name}" +""" + +example_modules_style_project_deps = """\ +"../../apps/{ns}/{app_name}" = "{ns}/{app_name}" +"../../libs/{ns}/{lib_name}" = "{ns}/{lib_name}" +""" + app_template = """\ import {ns}.{lib_name} as {lib_name} @@ -56,11 +91,10 @@ def run() -> None: print({lib_name}.greet()) """ -lib_dependencies = "cowsay==6.1" - lib_template = """\ import cowsay + def greet() -> str: return cowsay.say("Hello from una!") """ diff --git a/una/files.py b/una/files.py index cc2637c..9619c48 100644 --- a/una/files.py +++ b/una/files.py @@ -64,11 +64,13 @@ def collect_libs_paths(root: Path, ns: str, libs: set[str]) -> set[Path]: return collect_paths(root, ns, defaults.libs_dir, libs) -def update_workspace_config(path: Path, ns: str, style: Style) -> None: +def update_workspace_config(path: Path, ns: str, style: Style, dependencies: str) -> None: pyproj = path / defaults.pyproj if style == Style.modules: with pyproj.open() as f: toml = tomlkit.parse(f.read()) + toml["project"]["dependencies"].append(dependencies) # type:ignore[reportIndexIssues] + toml["tool"]["rye"]["workspace"] = {"member": ["projects/*"]} # type:ignore[reportIndexIssues] toml["tool"]["hatch"]["build"].add("dev-mode-dirs", ["libs", "apps"]) # type:ignore[reportIndexIssues] toml["tool"]["una"] = {"style": "modules"} # type:ignore[reportIndexIssues] with pyproj.open("w", encoding="utf-8") as f: @@ -84,21 +86,20 @@ def update_workspace_config(path: Path, ns: str, style: Style) -> None: def create_workspace(path: Path, ns: str, style: Style) -> None: - update_workspace_config(path, ns, style) - app_content = defaults.app_template.format(ns=ns, lib_name=defaults.example_lib) lib_content = defaults.lib_template - dependencies = '"cowsay-python==1.0.1"' + dependencies = f'"{defaults.example_import}"' + update_workspace_config(path, ns, style, defaults.example_import) if style == Style.modules: - create_project(path, "example_project", dependencies) + create_project(path, "example_project", dependencies, defaults.example_app) if style == Style.packages: create_package(path, defaults.example_app, defaults.apps_dir, app_content, "") create_package(path, defaults.example_lib, defaults.libs_dir, lib_content, dependencies) else: create_module(path, defaults.example_app, defaults.apps_dir, app_content) - create_module(path, defaults.example_lib, defaults.libs_dir, app_content) + create_module(path, defaults.example_lib, defaults.libs_dir, lib_content) def parse_package_paths(packages: list[Include]) -> list[Path]: @@ -106,15 +107,20 @@ def parse_package_paths(packages: list[Include]) -> list[Path]: return [Path(p.src) for p in sorted_packages] -def create_project(path: Path, name: str, dependencies: str) -> None: +def create_project(path: Path, name: str, dependencies: str, from_app: str) -> None: conf = config.load_conf(path) python_version = conf.project.requires_python + ns = config.get_ns(path) proj_dir = create_dir(path, f"projects/{name}") + content = defaults.projects_pyproj.format( + ns=ns, name=name, python_version=python_version, dependencies=dependencies + ) create_file( proj_dir, defaults.pyproj, - defaults.packages_pyproj.format(name=name, python_version=python_version, dependencies=""), + content + + defaults.example_modules_style_project_deps.format(ns=ns, app_name=from_app, lib_name=defaults.example_lib), ) @@ -124,16 +130,25 @@ def create_package(path: Path, name: str, top_dir: str, content: str, dependenci ns = config.get_ns(path) app_dir = create_dir(path, f"{top_dir}/{name}") + ns_dir = create_dir(path, f"{top_dir}/{name}/{ns}") code_dir = create_dir(path, f"{top_dir}/{name}/{ns}/{name}") test_dir = create_dir(path, f"{top_dir}/{name}/tests") create_file(code_dir, "__init__.py", content) create_file(code_dir, "py.typed") + is_app = top_dir == defaults.apps_dir # TODO fix this hack create_file(test_dir, f"test_{name}_import.py", content=defaults.test_template.format(ns=ns, name=name)) + pyproj_content = defaults.packages_pyproj.format( + name=name, python_version=python_version, dependencies=dependencies + ) + if is_app: + create_file(ns_dir, "py.typed") # is this necessary? basedpyright thinks so... + if content: + pyproj_content += defaults.example_packages_style_app_deps.format(ns=ns, lib_name=defaults.example_lib) create_file( app_dir, defaults.pyproj, - content=defaults.packages_pyproj.format(name=name, python_version=python_version, dependencies=dependencies), + content, )