├── development ├── __init__.py ├── david.py └── README.md ├── bases └── polylith │ ├── hatch_hooks │ ├── __init__.py │ └── hooks.py │ ├── pdm_project_hooks │ ├── __init__.py │ └── core.py │ ├── pdm_workspace_hooks │ ├── __init__.py │ └── core.py │ ├── cli │ ├── __init__.py │ ├── __main__.py │ ├── env.py │ ├── options.py │ ├── test.py │ ├── create.py │ └── build.py │ └── poetry_plugin │ ├── __init__.py │ └── plugin.py ├── components └── polylith │ ├── pdm │ ├── __init__.py │ ├── hooks │ │ ├── __init__.py │ │ ├── workspace.py │ │ └── bricks.py │ └── core.py │ ├── hatch │ ├── hooks │ │ ├── __init__.py │ │ └── bricks.py │ ├── __init__.py │ └── core.py │ ├── reporting │ ├── __init__.py │ └── theme.py │ ├── dirs │ ├── __init__.py │ └── dirs.py │ ├── yaml │ ├── __init__.py │ └── core.py │ ├── alias │ ├── __init__.py │ └── core.py │ ├── diff │ ├── __init__.py │ └── report.py │ ├── files │ ├── __init__.py │ └── files.py │ ├── interactive │ ├── __init__.py │ └── project.py │ ├── workspace │ ├── __init__.py │ ├── paths.py │ └── create.py │ ├── poetry │ ├── __init__.py │ ├── commands │ │ ├── create_base.py │ │ ├── command_options.py │ │ ├── create_component.py │ │ ├── info.py │ │ ├── sync.py │ │ ├── __init__.py │ │ ├── create_workspace.py │ │ ├── diff.py │ │ ├── create_project.py │ │ ├── deps.py │ │ ├── test.py │ │ ├── check.py │ │ └── libs.py │ └── internals.py │ ├── output │ ├── __init__.py │ └── core.py │ ├── check │ ├── __init__.py │ ├── grouping.py │ └── collect.py │ ├── interface │ ├── __init__.py │ └── interfaces.py │ ├── development │ ├── __init__.py │ └── development.py │ ├── environment │ ├── __init__.py │ └── core.py │ ├── readme │ ├── __init__.py │ └── readme.py │ ├── commands │ ├── __init__.py │ ├── info.py │ ├── create.py │ ├── sync.py │ ├── libs.py │ ├── deps.py │ └── test.py │ ├── parsing │ ├── __init__.py │ ├── core.py │ └── rewrite.py │ ├── sync │ ├── __init__.py │ ├── report.py │ └── collect.py │ ├── test │ ├── __init__.py │ ├── tests.py │ ├── core.py │ └── report.py │ ├── bricks │ ├── __init__.py │ ├── base.py │ ├── brick.py │ └── component.py │ ├── distributions │ ├── caching.py │ ├── __init__.py │ ├── collect.py │ └── core.py │ ├── imports │ └── __init__.py │ ├── building │ ├── __init__.py │ ├── paths.py │ └── core.py │ ├── project │ ├── __init__.py │ ├── parser.py │ ├── create.py │ ├── get.py │ └── templates.py │ ├── info │ ├── __init__.py │ ├── collect.py │ └── report.py │ ├── deps │ ├── __init__.py │ └── core.py │ ├── libs │ ├── __init__.py │ └── grouping.py │ ├── toml │ └── __init__.py │ ├── configuration │ ├── __init__.py │ └── core.py │ └── repo │ ├── __init__.py │ ├── get.py │ └── repo.py ├── test ├── components │ └── polylith │ │ ├── check │ │ ├── __init__.py │ │ ├── test_collect.py │ │ └── test_report.py │ │ ├── diff │ │ ├── __init__.py │ │ └── test_collect.py │ │ ├── hatch │ │ ├── __init__.py │ │ └── test_hook.py │ │ ├── output │ │ ├── __init__.py │ │ └── test_core.py │ │ ├── toml │ │ └── __init__.py │ │ ├── building │ │ ├── __init__.py │ │ └── test_paths.py │ │ ├── commands │ │ ├── __init__.py │ │ ├── test_diff.py │ │ └── test_sync.py │ │ ├── environment │ │ ├── __init__.py │ │ └── test_parse_paths.py │ │ ├── parsing │ │ ├── __init__.py │ │ └── test_core.py │ │ ├── configuration │ │ └── __init__.py │ │ ├── distributions │ │ ├── __init__.py │ │ └── test_collect.py │ │ ├── repo │ │ ├── test_repo.py │ │ └── test_repo_get.py │ │ ├── poetry │ │ └── test_internals.py │ │ ├── project │ │ ├── test_create.py │ │ ├── test_project_get.py │ │ └── test_templates.py │ │ ├── deps │ │ ├── test_deps_report.py │ │ └── test_deps_core.py │ │ ├── bricks │ │ └── test_base.py │ │ ├── libs │ │ ├── test_stdlib.py │ │ └── test_report.py │ │ └── alias │ │ └── test_alias.py ├── test_data │ ├── workspace.toml │ ├── rye │ ├── uv │ ├── pdm │ └── piptools └── conftest.py ├── poetry.toml ├── .gitignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── custom.md │ ├── feature_request.md │ └── bug-report.md └── PULL_REQUEST_TEMPLATE.md ├── .flake8 ├── workspace.toml ├── mypy.ini ├── .circleci └── config.yml ├── projects ├── pdm_polylith_bricks │ ├── poetry.lock │ ├── pyproject.toml │ └── README.md ├── pdm_polylith_workspace │ ├── poetry.lock │ ├── README.md │ └── pyproject.toml ├── hatch_polylith_bricks │ ├── pyproject.toml │ ├── README.md │ └── poetry.lock ├── poetry_polylith_plugin │ ├── README.md │ └── pyproject.toml └── polylith_cli │ └── pyproject.toml ├── CONTRIBUTING.md ├── LICENSE ├── CODE-OF-CONDUCT.md └── pyproject.toml /development/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bases/polylith/hatch_hooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/polylith/pdm/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/polylith/hatch/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/polylith/pdm/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/components/polylith/check/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/components/polylith/diff/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/components/polylith/hatch/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/components/polylith/output/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/components/polylith/toml/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bases/polylith/pdm_project_hooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bases/polylith/pdm_workspace_hooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/components/polylith/building/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/components/polylith/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/components/polylith/environment/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/components/polylith/parsing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/components/polylith/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/components/polylith/distributions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | path = ".venv" 3 | in-project = true 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__ 3 | .mypy_cache 4 | dist 5 | .coverage 6 | -------------------------------------------------------------------------------- /bases/polylith/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.cli import core 2 | 3 | __all__ = ["core"] 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: davidvujic 4 | -------------------------------------------------------------------------------- /bases/polylith/cli/__main__.py: -------------------------------------------------------------------------------- 1 | from polylith.cli.core import app 2 | 3 | app(prog_name="poly") 4 | -------------------------------------------------------------------------------- /components/polylith/hatch/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.hatch import hooks 2 | 3 | __all__ = ["hooks"] 4 | -------------------------------------------------------------------------------- /components/polylith/reporting/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.reporting import theme 2 | 3 | __all__ = ["theme"] 4 | -------------------------------------------------------------------------------- /components/polylith/dirs/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.dirs.dirs import create_dir 2 | 3 | __all__ = ["create_dir"] 4 | -------------------------------------------------------------------------------- /components/polylith/yaml/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.yaml.core import load_yaml 2 | 3 | __all__ = ["load_yaml"] 4 | -------------------------------------------------------------------------------- /components/polylith/alias/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.alias.core import parse, pick 2 | 3 | __all__ = ["parse", "pick"] 4 | -------------------------------------------------------------------------------- /components/polylith/diff/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.diff import collect, report 2 | 3 | __all__ = ["collect", "report"] 4 | -------------------------------------------------------------------------------- /components/polylith/files/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.files.files import create_file 2 | 3 | __all__ = ["create_file"] 4 | -------------------------------------------------------------------------------- /components/polylith/interactive/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.interactive import project 2 | 3 | __all__ = ["project"] 4 | -------------------------------------------------------------------------------- /components/polylith/workspace/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.workspace import create, paths 2 | 3 | __all__ = ["create", "paths"] 4 | -------------------------------------------------------------------------------- /components/polylith/poetry/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.poetry import commands, internals 2 | 3 | __all__ = ["commands", "internals"] 4 | -------------------------------------------------------------------------------- /bases/polylith/poetry_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.poetry_plugin.plugin import PolylithPlugin 2 | 3 | __all__ = ["PolylithPlugin"] 4 | -------------------------------------------------------------------------------- /components/polylith/output/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.output.core import save, save_recorded 2 | 3 | __all__ = ["save", "save_recorded"] 4 | -------------------------------------------------------------------------------- /components/polylith/check/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.check import collect, grouping, report 2 | 3 | __all__ = ["collect", "grouping", "report"] 4 | -------------------------------------------------------------------------------- /components/polylith/interface/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.interface.interfaces import create_interface 2 | 3 | __all__ = ["create_interface"] 4 | -------------------------------------------------------------------------------- /components/polylith/development/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.development.development import create_development 2 | 3 | __all__ = ["create_development"] 4 | -------------------------------------------------------------------------------- /components/polylith/environment/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.environment.core import add_paths, parse_paths 2 | 3 | __all__ = ["add_paths", "parse_paths"] 4 | -------------------------------------------------------------------------------- /components/polylith/readme/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.readme.readme import create_brick_readme, create_workspace_readme 2 | 3 | __all__ = ["create_brick_readme", "create_workspace_readme"] 4 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = 4 | .git, 5 | .github, 6 | __pycache__, 7 | .mypy_cache, 8 | dist, 9 | .venv, 10 | ./development/*.py, -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /components/polylith/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.commands import check, create, deps, diff, info, libs, sync, test 2 | 3 | __all__ = ["check", "create", "deps", "diff", "info", "libs", "sync", "test"] 4 | -------------------------------------------------------------------------------- /components/polylith/files/files.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def create_file(path: Path, name: str) -> Path: 5 | fullpath = path / name 6 | 7 | fullpath.touch() 8 | 9 | return fullpath 10 | -------------------------------------------------------------------------------- /bases/polylith/hatch_hooks/hooks.py: -------------------------------------------------------------------------------- 1 | from hatchling.plugin import hookimpl 2 | 3 | from polylith.hatch.hooks.bricks import PolylithBricksHook 4 | 5 | 6 | @hookimpl 7 | def hatch_register_build_hook(): 8 | return PolylithBricksHook 9 | -------------------------------------------------------------------------------- /components/polylith/parsing/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.parsing.core import copy_brick, parse_brick_namespace_from_path 2 | from polylith.parsing.rewrite import rewrite_modules 3 | 4 | __all__ = ["copy_brick", "parse_brick_namespace_from_path", "rewrite_modules"] 5 | -------------------------------------------------------------------------------- /components/polylith/pdm/hooks/workspace.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith import environment 4 | 5 | 6 | def build_initialize(config_data: dict, build_dir: Path, root: Path) -> None: 7 | environment.add_paths(config_data, build_dir, root) 8 | -------------------------------------------------------------------------------- /workspace.toml: -------------------------------------------------------------------------------- 1 | [tool.polylith] 2 | namespace = "polylith" 3 | 4 | [tool.polylith.structure] 5 | theme = "loose" 6 | 7 | [tool.polylith.tag.patterns] 8 | stable = "stable-*" 9 | release = "v[0-9]*" 10 | 11 | [tool.polylith.test] 12 | enabled = true 13 | -------------------------------------------------------------------------------- /components/polylith/development/development.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith.dirs import create_dir 4 | from polylith.repo import development_dir 5 | 6 | 7 | def create_development(path: Path, keep=True) -> None: 8 | create_dir(path, development_dir, keep=keep) 9 | -------------------------------------------------------------------------------- /components/polylith/sync/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.sync import report 2 | from polylith.sync.collect import calculate_diff, calculate_needed_bricks 3 | from polylith.sync.update import update_project 4 | 5 | __all__ = ["report", "calculate_diff", "calculate_needed_bricks", "update_project"] 6 | -------------------------------------------------------------------------------- /components/polylith/test/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.test import report 2 | from polylith.test.core import get_brick_imports_in_tests, get_changed_files 3 | from polylith.test.tests import create_test 4 | 5 | __all__ = ["report", "create_test", "get_brick_imports_in_tests", "get_changed_files"] 6 | -------------------------------------------------------------------------------- /components/polylith/reporting/theme.py: -------------------------------------------------------------------------------- 1 | from rich.theme import Theme 2 | 3 | poly_theme = Theme( 4 | { 5 | "data": "#999966", 6 | "proj": "#8A2BE2", 7 | "comp": "#32CD32", 8 | "base": "#6495ED", 9 | } 10 | ) 11 | 12 | check_emoji = ":heavy_check_mark:" 13 | -------------------------------------------------------------------------------- /components/polylith/bricks/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.bricks.base import create_base, get_bases_data 2 | from polylith.bricks.component import create_component, get_components_data 3 | 4 | __all__ = [ 5 | "create_base", 6 | "create_component", 7 | "get_bases_data", 8 | "get_components_data", 9 | ] 10 | -------------------------------------------------------------------------------- /test/components/polylith/output/test_core.py: -------------------------------------------------------------------------------- 1 | from polylith.output import core 2 | 3 | 4 | def test_adjust_output_return_string_without_emojis(): 5 | data = "checking ✔ left 👈 or 👉 right" 6 | 7 | expected = "checking X left <- or -> right" 8 | 9 | res = core.adjust(data) 10 | 11 | assert res == expected 12 | -------------------------------------------------------------------------------- /components/polylith/distributions/caching.py: -------------------------------------------------------------------------------- 1 | _cache = {} 2 | 3 | 4 | def add(key: str, value) -> None: 5 | _cache[key] = value 6 | 7 | 8 | def get(key: str): 9 | return _cache[key] 10 | 11 | 12 | def exists(key: str) -> bool: 13 | return key in _cache 14 | 15 | 16 | def clear() -> None: 17 | _cache.clear() 18 | -------------------------------------------------------------------------------- /components/polylith/imports/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.imports.parser import ( 2 | extract_top_ns, 3 | fetch_all_imports, 4 | fetch_excluded_imports, 5 | list_imports, 6 | ) 7 | 8 | __all__ = [ 9 | "extract_top_ns", 10 | "fetch_all_imports", 11 | "fetch_excluded_imports", 12 | "list_imports", 13 | ] 14 | -------------------------------------------------------------------------------- /test/test_data/workspace.toml: -------------------------------------------------------------------------------- 1 | [tool.polylith] 2 | namespace = "test_space" 3 | 4 | [tool.polylith.structure] 5 | theme = "loose" 6 | 7 | [tool.polylith.tag.patterns] 8 | stable = "stable-*" 9 | release = "v[0-9]*" 10 | 11 | [tool.polylith.test] 12 | enabled = true 13 | 14 | [tool.polylith.resources] 15 | brick_docs_enabled = false 16 | -------------------------------------------------------------------------------- /bases/polylith/pdm_project_hooks/core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith.pdm.hooks.bricks import build_initialize 4 | 5 | 6 | def pdm_build_initialize(context): 7 | context.ensure_build_dir() 8 | 9 | build_dir = Path(context.build_dir) 10 | 11 | build_initialize(context.config.root, context.config.data, build_dir) 12 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | mypy_path = components, bases 3 | namespace_packages = True 4 | explicit_package_bases = True 5 | 6 | python_version = 3.8 7 | 8 | [mypy-poetry.console.*] 9 | ignore_missing_imports = True 10 | 11 | [mypy-poetry.plugins.*] 12 | ignore_missing_imports = True 13 | 14 | [mypy-cleo.helpers.*] 15 | ignore_missing_imports = True 16 | -------------------------------------------------------------------------------- /components/polylith/dirs/dirs.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith.files import create_file 4 | 5 | keep_file_name = ".keep" 6 | 7 | 8 | def create_dir(path: Path, dir_name: str, keep=False) -> Path: 9 | d = path / dir_name 10 | d.mkdir(parents=True) 11 | 12 | if keep: 13 | create_file(d, keep_file_name) 14 | 15 | return d 16 | -------------------------------------------------------------------------------- /components/polylith/building/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.building.core import cleanup, copy_and_rewrite_bricks, copy_bricks_as_is 2 | from polylith.building.paths import calculate_destination_dir, get_work_dir 3 | 4 | __all__ = [ 5 | "calculate_destination_dir", 6 | "cleanup", 7 | "copy_and_rewrite_bricks", 8 | "copy_bricks_as_is", 9 | "get_work_dir", 10 | ] 11 | -------------------------------------------------------------------------------- /test/components/polylith/repo/test_repo.py: -------------------------------------------------------------------------------- 1 | from polylith.repo import repo 2 | 3 | 4 | def test_is_pep_621_ready(): 5 | name = {"name": "hello world"} 6 | 7 | poetry_section = {"tool": {"poetry": name}} 8 | project_section = {"project": name} 9 | 10 | assert repo.is_pep_621_ready(poetry_section) is False 11 | assert repo.is_pep_621_ready(project_section) is True 12 | -------------------------------------------------------------------------------- /components/polylith/yaml/core.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from pathlib import Path 3 | 4 | import yaml 5 | 6 | 7 | @lru_cache 8 | def load_yaml(path: Path) -> dict: 9 | with open(path, "rb") as f: 10 | try: 11 | return yaml.safe_load(f) 12 | except yaml.YAMLError as e: 13 | raise ValueError(f"Failed loading {path}: {repr(e)}") from e 14 | -------------------------------------------------------------------------------- /test/components/polylith/parsing/test_core.py: -------------------------------------------------------------------------------- 1 | from polylith.parsing import core 2 | 3 | 4 | expected_ns = "unittest" 5 | bricks = { 6 | f"../../bases/{expected_ns}/one": f"{expected_ns}/one", 7 | f"../../components/{expected_ns}/two": f"{expected_ns}/two", 8 | } 9 | 10 | 11 | def test_parse_brick_namespace_from_path(): 12 | res = core.parse_brick_namespace_from_path(bricks) 13 | 14 | assert res == expected_ns 15 | -------------------------------------------------------------------------------- /components/polylith/hatch/core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Union 3 | 4 | from polylith import building, toml 5 | 6 | 7 | def get_work_dir(config: dict) -> Path: 8 | return building.get_work_dir(config) 9 | 10 | 11 | def get_top_namespace(pyproject: dict, config: dict) -> Union[str, None]: 12 | top_ns = toml.get_custom_top_namespace_from_polylith_section(pyproject) 13 | 14 | return top_ns or config.get("top-namespace") 15 | -------------------------------------------------------------------------------- /components/polylith/project/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.project.create import create_project 2 | from polylith.project.get import ( 3 | get_packages_for_projects, 4 | get_project_name, 5 | get_project_template, 6 | get_toml, 7 | ) 8 | from polylith.project.parser import parse_package_paths 9 | 10 | __all__ = [ 11 | "create_project", 12 | "get_packages_for_projects", 13 | "get_project_name", 14 | "get_project_template", 15 | "get_toml", 16 | "parse_package_paths", 17 | ] 18 | -------------------------------------------------------------------------------- /components/polylith/distributions/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.distributions import caching 2 | from polylith.distributions.collect import known_aliases_and_sub_dependencies 3 | from polylith.distributions.core import ( 4 | distributions_packages, 5 | distributions_sub_packages, 6 | get_distributions, 7 | ) 8 | 9 | __all__ = [ 10 | "caching", 11 | "distributions_packages", 12 | "distributions_sub_packages", 13 | "get_distributions", 14 | "known_aliases_and_sub_dependencies", 15 | ] 16 | -------------------------------------------------------------------------------- /components/polylith/project/parser.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List 3 | 4 | 5 | def to_path(package: dict) -> Path: 6 | include = package["include"] 7 | from_path = package.get("from") 8 | 9 | return Path(f"{from_path}/{include}") if from_path else Path(include) 10 | 11 | 12 | def parse_package_paths(packages: List[dict]) -> List[Path]: 13 | sorted_packages = sorted(packages, key=lambda p: (p.get("from", "."), p["include"])) 14 | 15 | return [to_path(p) for p in sorted_packages] 16 | -------------------------------------------------------------------------------- /bases/polylith/pdm_workspace_hooks/core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith.pdm.hooks.workspace import build_initialize 4 | 5 | 6 | def pdm_build_initialize(context): 7 | """Adding an additional pth file to the virtual environment 8 | 9 | Making the virtual environment aware of the Polylith Workspace. 10 | """ 11 | 12 | context.ensure_build_dir() 13 | 14 | data = context.config.data 15 | build_dir = Path(context.build_dir) 16 | root = Path(context.config.root) 17 | 18 | build_initialize(data, build_dir, root) 19 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | orbs: 3 | python: circleci/python@2.1.1 4 | 5 | jobs: 6 | test: 7 | executor: 8 | name: python/default 9 | tag: "3.8" 10 | steps: 11 | - checkout 12 | - python/install-packages: 13 | pkg-manager: poetry 14 | - run: 15 | command: | 16 | python --version 17 | poetry run flake8 18 | poetry run mypy . 19 | poetry run pytest 20 | name: Linting and testing 21 | 22 | workflows: 23 | main: 24 | jobs: 25 | - test 26 | -------------------------------------------------------------------------------- /components/polylith/info/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.info.collect import ( 2 | find_unused_bases, 3 | get_bases, 4 | get_bricks_in_projects, 5 | get_components, 6 | get_projects_data, 7 | ) 8 | from polylith.info.report import ( 9 | is_project, 10 | print_bricks_in_projects, 11 | print_workspace_summary, 12 | ) 13 | 14 | __all__ = [ 15 | "find_unused_bases", 16 | "get_bases", 17 | "get_bricks_in_projects", 18 | "get_components", 19 | "get_projects_data", 20 | "is_project", 21 | "print_bricks_in_projects", 22 | "print_workspace_summary", 23 | ] 24 | -------------------------------------------------------------------------------- /components/polylith/commands/info.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith import configuration, info 4 | 5 | 6 | def run(root: Path, options: dict): 7 | ns = configuration.get_namespace_from_config(root) 8 | bases = info.get_bases(root, ns) 9 | components = info.get_components(root, ns) 10 | projects_data = info.get_bricks_in_projects(root, components, bases, ns) 11 | 12 | info.print_workspace_summary(projects_data, bases, components, options) 13 | 14 | if not components and not bases: 15 | return 16 | 17 | info.print_bricks_in_projects(projects_data, bases, components, options) 18 | -------------------------------------------------------------------------------- /components/polylith/deps/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.deps.core import ( 2 | calculate_brick_deps, 3 | find_bricks_with_circular_dependencies, 4 | get_brick_imports, 5 | ) 6 | from polylith.deps.report import ( 7 | print_brick_deps, 8 | print_brick_with_circular_deps, 9 | print_bricks_with_circular_deps, 10 | print_deps, 11 | ) 12 | 13 | __all__ = [ 14 | "calculate_brick_deps", 15 | "find_bricks_with_circular_dependencies", 16 | "get_brick_imports", 17 | "print_brick_deps", 18 | "print_brick_with_circular_deps", 19 | "print_bricks_with_circular_deps", 20 | "print_deps", 21 | ] 22 | -------------------------------------------------------------------------------- /components/polylith/bricks/base.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List 3 | 4 | from polylith.bricks import component 5 | from polylith.bricks.brick import create_brick 6 | from polylith.repo import bases_dir 7 | from polylith.test import create_test 8 | 9 | 10 | def create_base(path: Path, options: dict) -> None: 11 | extra = {"brick": bases_dir} 12 | base_options = {**options, **extra} 13 | 14 | create_brick(path, base_options) 15 | create_test(path, base_options) 16 | 17 | 18 | def get_bases_data(path: Path, ns: str) -> List[dict]: 19 | return component.get_components_data(path, ns, bases_dir) 20 | -------------------------------------------------------------------------------- /components/polylith/libs/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.libs import report 2 | from polylith.libs.grouping import extract_third_party_imports, get_third_party_imports 3 | from polylith.libs.lock_files import ( 4 | extract_libs, 5 | extract_workspace_member_libs, 6 | get_workspace_enabled_lock_file_data, 7 | is_from_lock_file, 8 | pick_lock_file, 9 | ) 10 | 11 | __all__ = [ 12 | "report", 13 | "extract_third_party_imports", 14 | "get_third_party_imports", 15 | "extract_libs", 16 | "extract_workspace_member_libs", 17 | "get_workspace_enabled_lock_file_data", 18 | "is_from_lock_file", 19 | "pick_lock_file", 20 | ] 21 | -------------------------------------------------------------------------------- /test/components/polylith/poetry/test_internals.py: -------------------------------------------------------------------------------- 1 | """ 2 | Using the dependencies of the development virtual environment 3 | to assert the Poetry internals itself, 4 | and catch any breaking features in upcoming versions. 5 | """ 6 | 7 | from pathlib import Path 8 | 9 | from polylith.poetry import internals 10 | 11 | 12 | def test_find_third_party_libs(): 13 | path = Path.cwd() 14 | res = internals.find_third_party_libs(path) 15 | 16 | expected = {"rich", "isort", "black"} 17 | 18 | assert expected.issubset(res) 19 | 20 | 21 | def test_distributions(): 22 | path = Path.cwd() 23 | 24 | res = internals.distributions(path) 25 | 26 | assert res is not None and len(list(res)) 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /projects/pdm_polylith_bricks/poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "tomlkit" 5 | version = "0.12.5" 6 | description = "Style preserving TOML library" 7 | optional = false 8 | python-versions = ">=3.7" 9 | files = [ 10 | {file = "tomlkit-0.12.5-py3-none-any.whl", hash = "sha256:af914f5a9c59ed9d0762c7b64d3b5d5df007448eb9cd2edc8a46b1eafead172f"}, 11 | {file = "tomlkit-0.12.5.tar.gz", hash = "sha256:eef34fba39834d4d6b73c9ba7f3e4d1c417a4e56f89a7e96e090dd0d24b8fb3c"}, 12 | ] 13 | 14 | [metadata] 15 | lock-version = "2.0" 16 | python-versions = "^3.9" 17 | content-hash = "51a03ee3e0a6a98a47d372ddda1d6f23390988f881583346a47b5135acf035ae" 18 | -------------------------------------------------------------------------------- /test/components/polylith/project/test_create.py: -------------------------------------------------------------------------------- 1 | import tomlkit 2 | from polylith.project.create import create_project_toml 3 | 4 | 5 | def test_create_project_toml(): 6 | name = "unit test project" 7 | description = "this is a unit test" 8 | authors = ["one", "two", "three"] 9 | python_version = "something" 10 | 11 | template = 'x = "{name}{description}{authors}{python_version}"' 12 | 13 | data = { 14 | "name": name, 15 | "description": description, 16 | "authors": authors, 17 | "python_version": python_version, 18 | } 19 | 20 | res = create_project_toml(template, data) 21 | 22 | assert tomlkit.dumps(res) == f'x = "{name}{description}{authors}{python_version}"' 23 | -------------------------------------------------------------------------------- /components/polylith/toml/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.toml.core import ( 2 | collect_configured_exclude_patterns, 3 | get_custom_top_namespace_from_polylith_section, 4 | get_project_dependencies, 5 | get_project_package_includes, 6 | get_project_packages_from_polylith_section, 7 | load_toml, 8 | parse_project_dependencies, 9 | read_toml_document, 10 | ) 11 | 12 | __all__ = [ 13 | "collect_configured_exclude_patterns", 14 | "get_custom_top_namespace_from_polylith_section", 15 | "get_project_dependencies", 16 | "get_project_package_includes", 17 | "get_project_packages_from_polylith_section", 18 | "load_toml", 19 | "parse_project_dependencies", 20 | "read_toml_document", 21 | ] 22 | -------------------------------------------------------------------------------- /projects/pdm_polylith_workspace/poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "tomlkit" 5 | version = "0.12.5" 6 | description = "Style preserving TOML library" 7 | optional = false 8 | python-versions = ">=3.7" 9 | files = [ 10 | {file = "tomlkit-0.12.5-py3-none-any.whl", hash = "sha256:af914f5a9c59ed9d0762c7b64d3b5d5df007448eb9cd2edc8a46b1eafead172f"}, 11 | {file = "tomlkit-0.12.5.tar.gz", hash = "sha256:eef34fba39834d4d6b73c9ba7f3e4d1c417a4e56f89a7e96e090dd0d24b8fb3c"}, 12 | ] 13 | 14 | [metadata] 15 | lock-version = "2.0" 16 | python-versions = "^3.9" 17 | content-hash = "51a03ee3e0a6a98a47d372ddda1d6f23390988f881583346a47b5135acf035ae" 18 | -------------------------------------------------------------------------------- /components/polylith/pdm/hooks/bricks.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith import toml 4 | from polylith.pdm import core 5 | 6 | 7 | def build_initialize(root: Path, config_data: dict, build_dir: Path) -> None: 8 | bricks = toml.get_project_packages_from_polylith_section(config_data) 9 | found_bricks = {k: v for k, v in bricks.items() if Path(root / k).exists()} 10 | 11 | if not bricks or not found_bricks: 12 | return 13 | 14 | top_ns = toml.get_custom_top_namespace_from_polylith_section(config_data) 15 | work_dir = core.get_work_dir(config_data) 16 | 17 | if not top_ns: 18 | core.copy_bricks_as_is(bricks, build_dir) 19 | else: 20 | core.copy_and_rewrite(bricks, top_ns, work_dir, build_dir) 21 | -------------------------------------------------------------------------------- /components/polylith/pdm/core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith import building 4 | 5 | 6 | def get_work_dir(config: dict) -> Path: 7 | build_config = config.get("tool", {}).get("pdm", {}).get("build", {}) 8 | 9 | return building.get_work_dir(build_config) 10 | 11 | 12 | def copy_bricks_as_is(bricks: dict, build_dir: Path) -> None: 13 | building.copy_bricks_as_is(bricks, build_dir) 14 | 15 | 16 | def copy_and_rewrite( 17 | bricks: dict, top_ns: str, work_dir: Path, build_dir: Path 18 | ) -> None: 19 | rewritten = building.copy_and_rewrite_bricks(bricks, top_ns, work_dir, build_dir) 20 | 21 | for item in rewritten: 22 | print(f"Updated {item} with new top namespace for local imports.") 23 | 24 | building.cleanup(work_dir) 25 | -------------------------------------------------------------------------------- /bases/polylith/cli/env.py: -------------------------------------------------------------------------------- 1 | import sysconfig 2 | from pathlib import Path 3 | 4 | from polylith import configuration, environment, info, repo 5 | from typer import Typer 6 | 7 | app = Typer() 8 | 9 | 10 | @app.command("setup") 11 | def setup_command(): 12 | """Setup the current virtual environment, by adding the bases and components paths as module root.""" 13 | root = repo.get_workspace_root(Path.cwd()) 14 | ns = configuration.get_namespace_from_config(root) 15 | 16 | projects_data = info.get_projects_data(root, ns) 17 | dev_project = next((p for p in projects_data if p["type"] == "development"), None) 18 | 19 | if not dev_project: 20 | return 21 | 22 | env_dir = Path(sysconfig.get_paths().get("purelib")) 23 | 24 | environment.add_paths(dev_project, env_dir, root) 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the development of python-polylith 2 | 3 | Fork this repo, write code and send a pull request. Easy! 4 | 5 | ### Guidelines 6 | * Create a branch for the feature you are about to write. 7 | * If possible, isolate the changes in the branch to the feature only. Merges will be easier if general refactorings, that are not specific to the feature, are made in separate branches. 8 | * If possible, avoid general dependency updates in the feature branch. 9 | * If possible, send pull requests early. Don't wait too long, even if the feature is not completely done. The new code can very likely be merged without causing any problems (if the code changes are not of type breaking features). Think of it as "silent releases". 10 | * If you think it is relevant and adds value: write unit test(s). 11 | -------------------------------------------------------------------------------- /bases/polylith/cli/options.py: -------------------------------------------------------------------------------- 1 | from typer import Option 2 | 3 | alias = Option( 4 | help="alias for third-party libraries, useful when an import differ from the library name" 5 | ) 6 | directory = Option( 7 | help="The working directory for the command (defaults to the current working directory)." 8 | ) 9 | 10 | short = Option(help="Print short view.") 11 | short_workspace = Option(help="Display Workspace Info adjusted for many projects.") 12 | 13 | strict = Option( 14 | help="More strict checks when matching name and version of third-party libraries and imports." 15 | ) 16 | 17 | verbose = Option(help="More verbose output.") 18 | quiet = Option(help="Do not output any messages.") 19 | 20 | brick = Option(help="Shows dependencies for selected brick.") 21 | save = Option(help="Store the contents of this command to file.") 22 | -------------------------------------------------------------------------------- /components/polylith/commands/create.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Union 3 | 4 | from polylith import configuration, repo 5 | 6 | 7 | def create(name: Union[str, None], description: Union[str, None], fn): 8 | root = repo.get_workspace_root(Path.cwd()) 9 | namespace = configuration.get_namespace_from_config(root) 10 | 11 | if not name: 12 | raise ValueError("Please add a name by using --name") 13 | 14 | if not namespace: 15 | raise ValueError( 16 | "Didn't find a namespace. Expected to find it under [tool.polylith] in workspace.toml or pyproject.toml." 17 | ) 18 | 19 | options = { 20 | "namespace": namespace, 21 | "package": name, 22 | "description": description, 23 | "modulename": "core", 24 | } 25 | fn(root, options) 26 | -------------------------------------------------------------------------------- /components/polylith/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.configuration.core import ( 2 | get_brick_structure_from_config, 3 | get_namespace_from_config, 4 | get_output_dir, 5 | get_resources_structure_from_config, 6 | get_tag_pattern_from_config, 7 | get_tag_sort_options_from_config, 8 | get_tests_structure_from_config, 9 | get_theme_from_config, 10 | is_readme_generation_enabled, 11 | is_test_generation_enabled, 12 | ) 13 | 14 | __all__ = [ 15 | "get_brick_structure_from_config", 16 | "get_namespace_from_config", 17 | "get_output_dir", 18 | "get_resources_structure_from_config", 19 | "get_tag_pattern_from_config", 20 | "get_tag_sort_options_from_config", 21 | "get_tests_structure_from_config", 22 | "get_theme_from_config", 23 | "is_readme_generation_enabled", 24 | "is_test_generation_enabled", 25 | ] 26 | -------------------------------------------------------------------------------- /components/polylith/repo/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.repo.get import get_authors, get_python_version 2 | from polylith.repo.repo import ( 3 | bases_dir, 4 | components_dir, 5 | default_toml, 6 | development_dir, 7 | get_workspace_root, 8 | is_hatch, 9 | is_pdm, 10 | is_pep_621_ready, 11 | is_poetry, 12 | is_uv, 13 | load_workspace_config, 14 | projects_dir, 15 | readme_file, 16 | workspace_file, 17 | ) 18 | 19 | __all__ = [ 20 | "get_authors", 21 | "get_python_version", 22 | "bases_dir", 23 | "components_dir", 24 | "default_toml", 25 | "development_dir", 26 | "get_workspace_root", 27 | "is_hatch", 28 | "is_pdm", 29 | "is_pep_621_ready", 30 | "is_poetry", 31 | "is_uv", 32 | "load_workspace_config", 33 | "projects_dir", 34 | "readme_file", 35 | "workspace_file", 36 | ] 37 | -------------------------------------------------------------------------------- /components/polylith/poetry/commands/create_base.py: -------------------------------------------------------------------------------- 1 | from cleo.helpers import option 2 | from poetry.console.commands.command import Command 3 | from polylith.bricks import base 4 | from polylith.commands.create import create 5 | 6 | 7 | class CreateBaseCommand(Command): 8 | name = "poly create base" 9 | description = "Creates a Polylith base." 10 | 11 | options = [ 12 | option("name", None, "Name of the base.", flag=False), 13 | option( 14 | "description", 15 | None, 16 | "Description of the base.", 17 | flag=False, 18 | value_required=False, 19 | ), 20 | ] 21 | 22 | def handle(self) -> int: 23 | name = self.option("name") 24 | description = self.option("description") 25 | 26 | create(name, description, base.create_base) 27 | 28 | return 0 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Open a terminal window 16 | 2. Go to root of the project 17 | 4. Run the command '...' 18 | 5. See error 19 | 20 | **Error Log** 21 | If applicable, add the error logs. 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **Desktop (please complete the following information):** 30 | - OS: 31 | - Python version: 32 | - Poetry version: 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /test/components/polylith/deps/test_deps_report.py: -------------------------------------------------------------------------------- 1 | from polylith.deps.report import create_rows 2 | 3 | bases_in_workspace = {"x"} 4 | components_in_workspace = {"a", "b"} 5 | 6 | 7 | def test_to_row_returns_columns_for_all_bricks(): 8 | expected_length = len(bases_in_workspace) + len(components_in_workspace) 9 | 10 | # the base x imports the component a 11 | # the component a imports the component b 12 | # the component b imports nothing 13 | collected_import_data = {"x": {"a"}, "a": {"b"}} 14 | flattened_imports = set().union(*collected_import_data.values()) 15 | 16 | rows = create_rows( 17 | bases_in_workspace, 18 | components_in_workspace, 19 | collected_import_data, 20 | flattened_imports, 21 | ) 22 | 23 | assert len(rows) == expected_length 24 | 25 | for columns in rows: 26 | assert len(columns) == expected_length 27 | -------------------------------------------------------------------------------- /test/test_data/rye: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | # generate-hashes: false 10 | 11 | -e file:. 12 | annotated-types==0.7.0 13 | # via pydantic 14 | anyio==4.4.0 15 | # via fastapi 16 | # via starlette 17 | click==8.1.7 18 | # via uvicorn 19 | fastapi==0.109.2 20 | # via my-fastapi-project 21 | h11==0.14.0 22 | # via uvicorn 23 | idna==3.7 24 | # via anyio 25 | pydantic==2.7.4 26 | # via fastapi 27 | pydantic-core==2.18.4 28 | # via pydantic 29 | sniffio==1.3.1 30 | # via anyio 31 | starlette==0.36.3 32 | # via fastapi 33 | typing-extensions==4.12.2 34 | # via fastapi 35 | # via pydantic 36 | # via pydantic-core 37 | uvicorn==0.25.0 38 | # via my-fastapi-project 39 | -------------------------------------------------------------------------------- /components/polylith/poetry/commands/command_options.py: -------------------------------------------------------------------------------- 1 | from cleo.helpers import option 2 | 3 | alias = option( 4 | long_name="alias", 5 | description="alias for a third-party library, useful when an import differ from the library name", 6 | flag=False, 7 | multiple=True, 8 | ) 9 | 10 | 11 | save = option( 12 | long_name="save", 13 | description="Store the contents of this command to file", 14 | flag=True, 15 | ) 16 | 17 | 18 | short = option( 19 | long_name="short", 20 | short_name="s", 21 | description="Print short view", 22 | flag=True, 23 | ) 24 | 25 | since = option( 26 | long_name="since", 27 | description="Changed since a specific tag", 28 | flag=False, 29 | ) 30 | 31 | strict = option( 32 | long_name="strict", 33 | description="More strict checks when matching name and version of third-party libraries and imports.", 34 | flag=True, 35 | ) 36 | -------------------------------------------------------------------------------- /components/polylith/poetry/commands/create_component.py: -------------------------------------------------------------------------------- 1 | from cleo.helpers import option 2 | from poetry.console.commands.command import Command 3 | from polylith.bricks import component 4 | from polylith.commands.create import create 5 | 6 | 7 | class CreateComponentCommand(Command): 8 | name = "poly create component" 9 | description = "Creates a Polylith component." 10 | 11 | options = [ 12 | option("name", None, "Name of the component.", flag=False), 13 | option( 14 | "description", 15 | None, 16 | "Description of the component.", 17 | flag=False, 18 | value_required=False, 19 | ), 20 | ] 21 | 22 | def handle(self) -> int: 23 | name = self.option("name") 24 | description = self.option("description") 25 | 26 | create(name, description, component.create_component) 27 | 28 | return 0 29 | -------------------------------------------------------------------------------- /test/components/polylith/commands/test_diff.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith import commands 4 | 5 | 6 | def test_calculate_dependent_bricks(monkeypatch): 7 | fake_imports = { 8 | "one": {"three"}, 9 | "two": {"three"}, 10 | "three": {"four"}, 11 | "four": {}, 12 | "base_one": {"one", "four"}, 13 | "base_two": {"two", "four"}, 14 | } 15 | monkeypatch.setattr( 16 | commands.diff, "get_imports", lambda *args, **kwargs: fake_imports 17 | ) 18 | 19 | projects_data = [ 20 | {"bases": ["base_one", "base_two"], "components": ["one", "two", "three"]} 21 | ] 22 | 23 | changed_bricks = {"one", "three"} 24 | 25 | res = commands.diff.calculate_dependent_bricks( 26 | Path.cwd(), "test", projects_data, changed_bricks 27 | ) 28 | 29 | assert res["bases"] == {"base_one"} 30 | assert res["components"] == {"two"} 31 | -------------------------------------------------------------------------------- /test/components/polylith/environment/test_parse_paths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import tomlkit 4 | from polylith.environment import parse_paths 5 | 6 | root = "/some/path" 7 | namespace = "my_namespace" 8 | 9 | toml_with_tdd_theme = f"""\ 10 | [tool.polylith.bricks] 11 | "bases/one/src/{namespace}/one" = "{namespace}/one" 12 | "components/two/src/{namespace}/two" = "{namespace}/two" 13 | 14 | [build-system] 15 | requires = ["pdm-backend"] 16 | build-backend = "pdm.backend" 17 | """ 18 | 19 | 20 | def test_parse_paths_for_loose_theme(): 21 | res = parse_paths(Path(root), "loose", namespace, {}) 22 | 23 | assert res == {f"{root}/bases", f"{root}/components"} 24 | 25 | 26 | def test_parse_paths_for_tdd_theme(): 27 | data = tomlkit.loads(toml_with_tdd_theme) 28 | 29 | res = parse_paths(Path(root), "tdd", namespace, data) 30 | 31 | assert res == {f"{root}/bases/one/src", f"{root}/components/two/src"} 32 | -------------------------------------------------------------------------------- /components/polylith/poetry/commands/info.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from poetry.console.commands.command import Command 4 | from polylith import commands, configuration, repo 5 | from polylith.poetry.commands import command_options 6 | 7 | 8 | class InfoCommand(Command): 9 | name = "poly info" 10 | description = "Info about the Polylith workspace." 11 | 12 | options = [command_options.save, command_options.short] 13 | 14 | def handle(self) -> int: 15 | short = True if self.option("short") else False 16 | save = self.option("save") 17 | 18 | root = repo.get_workspace_root(Path.cwd()) 19 | output = configuration.get_output_dir(root, "info") if save else None 20 | 21 | options = { 22 | "short": short, 23 | "save": save, 24 | "output": output, 25 | } 26 | commands.info.run(root, options) 27 | 28 | return 0 29 | -------------------------------------------------------------------------------- /components/polylith/building/paths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Union 3 | 4 | from polylith import toml 5 | 6 | 7 | def get_work_dir(options: dict) -> Path: 8 | work_dir = options.get("work-dir", ".polylith_tmp") 9 | 10 | return Path(work_dir) 11 | 12 | 13 | def calculate_root_dir(bricks: dict) -> Union[str, None]: 14 | brick_path = next((v for v in bricks.values()), None) 15 | 16 | return str.split(brick_path, "/")[0] if brick_path else None 17 | 18 | 19 | def calculate_destination_dir(data: dict) -> Union[Path, None]: 20 | bricks = toml.get_project_packages_from_polylith_section(data) 21 | 22 | if not bricks: 23 | return None 24 | 25 | custom_top_ns = toml.get_custom_top_namespace_from_polylith_section(data) 26 | 27 | if custom_top_ns: 28 | return Path(custom_top_ns) 29 | 30 | root = calculate_root_dir(bricks) 31 | 32 | return Path(root) if root else None 33 | -------------------------------------------------------------------------------- /test/components/polylith/check/test_collect.py: -------------------------------------------------------------------------------- 1 | from polylith.check import collect 2 | 3 | brick_imports = { 4 | "bases": {"base_one": {"base_one", "component_one"}}, 5 | "components": { 6 | "component_one": {"component_one", "component_two", "component_tree"}, 7 | "component_two": {"component_two"}, 8 | "component_three": {"component_three"}, 9 | }, 10 | } 11 | 12 | bases = {"base_one"} 13 | 14 | 15 | def test_find_unused_bricks_returns_no_unused_bricks(): 16 | components = {"component_one", "component_two", "component_tree"} 17 | 18 | res = collect.find_unused_bricks(brick_imports, bases, components) 19 | 20 | assert res == set() 21 | 22 | 23 | def test_find_unused_bricks_returns_unused_bricks(): 24 | expected = {"component_four"} 25 | components = set().union({"component_one", "component_two"}, expected) 26 | 27 | res = collect.find_unused_bricks(brick_imports, bases, components) 28 | 29 | assert res == expected 30 | -------------------------------------------------------------------------------- /components/polylith/poetry/commands/sync.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from poetry.console.commands.command import Command 4 | from polylith import commands, configuration, info, repo 5 | from polylith.poetry.internals import filter_projects_data 6 | 7 | 8 | class SyncCommand(Command): 9 | name = "poly sync" 10 | description = "Update pyproject.toml with missing bricks." 11 | 12 | def handle(self) -> int: 13 | directory = self.option("directory") 14 | root = repo.get_workspace_root(Path.cwd()) 15 | ns = configuration.get_namespace_from_config(root) 16 | 17 | all_projects_data = info.get_projects_data(root, ns) 18 | projects_data = filter_projects_data(self.poetry, directory, all_projects_data) 19 | 20 | options = {"verbose": self.option("verbose"), "quiet": self.option("quiet")} 21 | 22 | for data in projects_data: 23 | commands.sync.run(root, ns, data, options) 24 | 25 | return 0 26 | -------------------------------------------------------------------------------- /test/test_data/uv: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.8" 3 | environment-markers = [ 4 | "python_full_version < '3.13'", 5 | "python_full_version >= '3.13'", 6 | ] 7 | 8 | [[package]] 9 | name = "annotated-types" 10 | version = "0.7.0" 11 | 12 | 13 | [[package]] 14 | name = "anyio" 15 | version = "4.4.0" 16 | 17 | [[package]] 18 | name = "click" 19 | version = "8.1.7" 20 | 21 | [[package]] 22 | name = "fastapi" 23 | version = "0.109.2" 24 | 25 | [[package]] 26 | name = "h11" 27 | version = "0.14.0" 28 | 29 | [[package]] 30 | name = "idna" 31 | version = "3.7" 32 | 33 | [[package]] 34 | name = "pydantic" 35 | version = "2.7.4" 36 | 37 | [[package]] 38 | name = "pydantic-core" 39 | version = "2.18.4" 40 | 41 | [[package]] 42 | name = "sniffio" 43 | version = "1.3.1" 44 | 45 | [[package]] 46 | name = "starlette" 47 | version = "0.36.3" 48 | 49 | [[package]] 50 | name = "typing-extensions" 51 | version = "4.12.2" 52 | 53 | [[package]] 54 | name = "uvicorn" 55 | version = "0.25.0" 56 | -------------------------------------------------------------------------------- /test/components/polylith/commands/test_sync.py: -------------------------------------------------------------------------------- 1 | from polylith.commands import sync 2 | 3 | 4 | def test_can_run_interactive_mode_depending_on_the_quite_mode() -> None: 5 | project_data = {"type": "project", "bases": [], "components": []} 6 | with_bricks = {"type": "project", "bases": [], "components": ["one", "two"]} 7 | 8 | assert sync.can_run_interactive_mode(project_data, {"quiet": False}) is True 9 | 10 | assert sync.can_run_interactive_mode(project_data, {"quiet": True}) is False 11 | assert sync.can_run_interactive_mode(with_bricks, {"quiet": True}) is False 12 | 13 | 14 | def test_can_run_interactive_mode_returns_false_for_project_with_bricks() -> None: 15 | options = {"quiet": False} 16 | with_bases = {"type": "project", "bases": ["my_base"], "components": []} 17 | with_components = {"type": "project", "bases": [], "components": ["one", "two"]} 18 | 19 | assert sync.can_run_interactive_mode(with_bases, options) is False 20 | assert sync.can_run_interactive_mode(with_components, options) is False 21 | -------------------------------------------------------------------------------- /components/polylith/repo/get.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from pathlib import Path 3 | 4 | import tomlkit 5 | from polylith.repo.repo import default_toml, is_pep_621_ready 6 | 7 | 8 | @lru_cache 9 | def get_pyproject_data(path: Path) -> tomlkit.TOMLDocument: 10 | with open(str(path / default_toml), "r", errors="ignore") as f: 11 | return tomlkit.loads(f.read()) 12 | 13 | 14 | def get_metadata_section(data: dict) -> dict: 15 | return data["project"] if is_pep_621_ready(data) else data["tool"]["poetry"] 16 | 17 | 18 | def get_authors(path: Path) -> str: 19 | data = get_pyproject_data(path) 20 | section = get_metadata_section(data) 21 | 22 | authors = section.get("authors") 23 | 24 | return authors.as_string() if authors else "" 25 | 26 | 27 | def get_python_version(path: Path) -> str: 28 | data: dict = get_pyproject_data(path) 29 | 30 | if is_pep_621_ready(data): 31 | return data["project"].get("requires-python", "") 32 | 33 | return data["tool"]["poetry"]["dependencies"]["python"] 34 | -------------------------------------------------------------------------------- /components/polylith/alias/core.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | from typing import Dict, List, Set 3 | 4 | 5 | def _to_key_with_values(acc: Dict, alias: str) -> Dict: 6 | k, v = str.split(alias, "=") 7 | 8 | values = [str.strip(val) for val in str.split(v, ",")] 9 | 10 | return {**acc, **{k: values}} 11 | 12 | 13 | def parse(aliases: List[str]) -> Dict[str, List[str]]: 14 | """Parse a list of aliases defined as key=value(s) into a dictionary""" 15 | return reduce(_to_key_with_values, aliases, {}) 16 | 17 | 18 | def _normalized_name(name: str) -> str: 19 | chars = {"-", "."} 20 | 21 | normalized = reduce(lambda acc, char: str.replace(acc, char, "_"), chars, name) 22 | 23 | return str.lower(normalized) 24 | 25 | 26 | def pick(aliases: Dict[str, List[str]], keys: Set) -> Set: 27 | normalized_keys = {_normalized_name(k) for k in keys} 28 | 29 | matrix = [v for k, v in aliases.items() if _normalized_name(k) in normalized_keys] 30 | 31 | flattened: List = sum(matrix, []) 32 | 33 | return set(flattened) 34 | -------------------------------------------------------------------------------- /test/test_data/pdm: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # It is not intended for manual editing. 3 | 4 | [metadata] 5 | groups = ["default"] 6 | strategy = ["cross_platform", "inherit_metadata"] 7 | lock_version = "4.4.1" 8 | content_hash = "sha256:111" 9 | 10 | [[package]] 11 | name = "annotated-types" 12 | version = "0.7.0" 13 | 14 | [[package]] 15 | name = "anyio" 16 | version = "4.4.0" 17 | 18 | [[package]] 19 | name = "click" 20 | version = "8.1.7" 21 | 22 | [[package]] 23 | name = "fastapi" 24 | version = "0.109.2" 25 | 26 | [[package]] 27 | name = "h11" 28 | version = "0.14.0" 29 | 30 | [[package]] 31 | name = "idna" 32 | version = "3.7" 33 | 34 | [[package]] 35 | name = "pydantic" 36 | version = "2.7.4" 37 | 38 | [[package]] 39 | name = "pydantic-core" 40 | version = "2.18.4" 41 | 42 | [[package]] 43 | name = "sniffio" 44 | version = "1.3.1" 45 | 46 | [[package]] 47 | name = "starlette" 48 | version = "0.36.3" 49 | 50 | [[package]] 51 | name = "typing-extensions" 52 | version = "4.12.2" 53 | 54 | [[package]] 55 | name = "uvicorn" 56 | version = "0.25.0" 57 | -------------------------------------------------------------------------------- /components/polylith/bricks/brick.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith import configuration 4 | from polylith.dirs import create_dir 5 | from polylith.files import create_file 6 | from polylith.interface import create_interface 7 | from polylith.readme import create_brick_readme 8 | 9 | 10 | def create_brick(root: Path, options: dict) -> None: 11 | modulename = options["modulename"] 12 | path_kwargs = { 13 | k: v for k, v in options.items() if k in {"brick", "namespace", "package"} 14 | } 15 | 16 | brick_structure = configuration.get_brick_structure_from_config(root) 17 | resources_structure = configuration.get_resources_structure_from_config(root) 18 | 19 | brick_path = brick_structure.format(**path_kwargs) 20 | resources_path = resources_structure.format(**path_kwargs) 21 | 22 | d = create_dir(root, brick_path) 23 | create_file(d, f"{modulename}.py") 24 | create_interface(d, options) 25 | 26 | if configuration.is_readme_generation_enabled(root): 27 | create_brick_readme(root / resources_path, options) 28 | -------------------------------------------------------------------------------- /components/polylith/workspace/paths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Set 3 | 4 | from polylith import configuration, repo 5 | 6 | 7 | def get_path(structure: str, brick: str, ns: str, package: str) -> str: 8 | return structure.format(brick=brick, namespace=ns, package=package) 9 | 10 | 11 | def get_paths(structure: str, brick: str, ns: str, packages: Set[str]) -> Set[str]: 12 | return {get_path(structure, brick, ns, p) for p in packages} 13 | 14 | 15 | def collect_paths(root: Path, ns: str, brick: str, packages: Set[str]) -> Set[Path]: 16 | structure = configuration.get_brick_structure_from_config(root) 17 | 18 | paths = get_paths(structure, brick, ns, packages) 19 | 20 | return {Path(root / p) for p in paths} 21 | 22 | 23 | def collect_bases_paths(root: Path, ns: str, bases: Set[str]) -> Set[Path]: 24 | return collect_paths(root, ns, repo.bases_dir, bases) 25 | 26 | 27 | def collect_components_paths(root: Path, ns: str, components: Set[str]) -> Set[Path]: 28 | return collect_paths(root, ns, repo.components_dir, components) 29 | -------------------------------------------------------------------------------- /components/polylith/poetry/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from polylith.poetry.commands.check import CheckCommand 2 | from polylith.poetry.commands.create_base import CreateBaseCommand 3 | from polylith.poetry.commands.create_component import CreateComponentCommand 4 | from polylith.poetry.commands.create_project import CreateProjectCommand 5 | from polylith.poetry.commands.create_workspace import CreateWorkspaceCommand 6 | from polylith.poetry.commands.deps import DepsCommand 7 | from polylith.poetry.commands.diff import DiffCommand 8 | from polylith.poetry.commands.info import InfoCommand 9 | from polylith.poetry.commands.libs import LibsCommand 10 | from polylith.poetry.commands.sync import SyncCommand 11 | from polylith.poetry.commands.test import TestDiffCommand 12 | 13 | __all__ = [ 14 | "CheckCommand", 15 | "CreateBaseCommand", 16 | "CreateComponentCommand", 17 | "CreateProjectCommand", 18 | "CreateWorkspaceCommand", 19 | "DepsCommand", 20 | "DiffCommand", 21 | "InfoCommand", 22 | "LibsCommand", 23 | "SyncCommand", 24 | "TestDiffCommand", 25 | ] 26 | -------------------------------------------------------------------------------- /components/polylith/test/tests.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith.dirs import create_dir 4 | from polylith.files import create_file 5 | from polylith import configuration 6 | 7 | template = """\ 8 | from {namespace}.{package} import {modulename} 9 | 10 | 11 | def test_sample(): 12 | assert {modulename} is not None 13 | """ 14 | 15 | 16 | def create_test(root: Path, options: dict) -> None: 17 | if not configuration.is_test_generation_enabled(root): 18 | return 19 | 20 | brick = options["brick"] 21 | namespace = options["namespace"] 22 | package = options["package"] 23 | modulename = options["modulename"] 24 | 25 | dirs_structure = configuration.get_tests_structure_from_config(root) 26 | dirs = dirs_structure.format(brick=brick, namespace=namespace, package=package) 27 | d = create_dir(root, dirs) 28 | 29 | create_file(d, "__init__.py") 30 | test_file = create_file(d, f"test_{modulename}.py") 31 | 32 | content = template.format( 33 | namespace=namespace, package=package, modulename=modulename 34 | ) 35 | 36 | test_file.write_text(content) 37 | -------------------------------------------------------------------------------- /components/polylith/sync/report.py: -------------------------------------------------------------------------------- 1 | from polylith import check 2 | from polylith.reporting import theme 3 | from rich.console import Console 4 | 5 | 6 | def print_brick_imports(diff: dict) -> None: 7 | brick_imports = diff["brick_imports"] 8 | 9 | check.report.print_brick_imports(brick_imports) 10 | 11 | 12 | def print_summary(diff: dict) -> None: 13 | console = Console(theme=theme.poly_theme) 14 | 15 | is_project = diff["is_project"] 16 | name = diff["name"] if is_project else "development" 17 | bases = diff["bases"] 18 | components = diff["components"] 19 | 20 | anything_to_sync = bases or components 21 | 22 | emoji = ":point_right:" if anything_to_sync else theme.check_emoji 23 | printable_name = f"[proj]{name}[/]" if is_project else f"[data]{name}[/]" 24 | 25 | console.print(f"{emoji} {printable_name}") 26 | 27 | for b in bases: 28 | console.print(f"adding [base]{b}[/] base to [proj]{name}[/]") 29 | 30 | for c in components: 31 | console.print(f"adding [comp]{c}[/] component to [proj]{name}[/]") 32 | 33 | if anything_to_sync: 34 | console.print("") 35 | -------------------------------------------------------------------------------- /components/polylith/interface/interfaces.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith.files import create_file 4 | 5 | template_docstring = """\ 6 | \"\"\" 7 | {description} 8 | \"\"\" 9 | 10 | """ 11 | 12 | template_content = """\ 13 | from {namespace}.{package} import {modulename} 14 | 15 | __all__ = ["{modulename}"] 16 | """ 17 | 18 | 19 | def to_namespaced_path(package: str) -> str: 20 | parts = package.split("/") 21 | 22 | return ".".join(parts) 23 | 24 | 25 | def create_interface(path: Path, options: dict) -> None: 26 | interface = create_file(path, "__init__.py") 27 | 28 | namespace = options["namespace"] 29 | package = options["package"] 30 | description = options["description"] 31 | modulename = options["modulename"] 32 | 33 | package_path = to_namespaced_path(package) 34 | 35 | docstring = ( 36 | template_docstring.format(description=description) if description else "" 37 | ) 38 | 39 | content = template_content.format( 40 | namespace=namespace, package=package_path, modulename=modulename 41 | ) 42 | 43 | interface.write_text(docstring + content) 44 | -------------------------------------------------------------------------------- /components/polylith/poetry/commands/create_workspace.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from cleo.helpers import option 4 | from poetry.console.commands.command import Command 5 | from polylith.workspace.create import create_workspace 6 | 7 | 8 | class CreateWorkspaceCommand(Command): 9 | name = "poly create workspace" 10 | description = "Creates a Polylith workspace in the current directory." 11 | 12 | options = [ 13 | option(long_name="name", description="Name of the workspace.", flag=False), 14 | option( 15 | long_name="theme", 16 | description="Workspace theme", 17 | flag=False, 18 | default="tdd", 19 | ), 20 | ] 21 | 22 | def handle(self) -> int: 23 | path = Path.cwd() 24 | namespace = self.option("name") 25 | theme = self.option("theme") 26 | 27 | if not namespace: 28 | raise ValueError( 29 | "Please add a workspace name. Poetry poly create workspace --name myname" 30 | ) 31 | 32 | create_workspace(path, namespace, theme) 33 | 34 | return 0 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 David Vujic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/components/polylith/building/test_paths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | import tomlkit 5 | from polylith.building import paths 6 | 7 | toml_data = """\ 8 | [tool.polylith.bricks] 9 | "../../components/my_namespace/my_brick" = "my_namespace/my_brick" 10 | """ 11 | 12 | top_ns_toml_data = """\ 13 | [tool.polylith.build] 14 | top-namespace = "helloworld" 15 | 16 | [tool.polylith.bricks] 17 | "../../components/my_namespace/my_brick" = "my_namespace/my_brick" 18 | """ 19 | 20 | toml_data_without_bricks = """\ 21 | [tool.polylith.bricks] 22 | """ 23 | 24 | toml_data_with_other_includes = """\ 25 | [tool.polylith.bricks] 26 | "" = "" 27 | """ 28 | 29 | 30 | @pytest.mark.parametrize( 31 | "config, expected", 32 | [ 33 | (toml_data, Path("my_namespace")), 34 | (top_ns_toml_data, Path("helloworld")), 35 | (toml_data_without_bricks, None), 36 | (toml_data_with_other_includes, None), 37 | ], 38 | ) 39 | def test_calculate_destination_dir(config, expected): 40 | data = tomlkit.loads(config) 41 | 42 | res = paths.calculate_destination_dir(data) 43 | 44 | assert res == expected 45 | -------------------------------------------------------------------------------- /components/polylith/poetry/commands/diff.py: -------------------------------------------------------------------------------- 1 | from cleo.helpers import option 2 | from poetry.console.commands.command import Command 3 | from polylith import commands 4 | from polylith.poetry.commands import command_options 5 | 6 | 7 | class DiffCommand(Command): 8 | name = "poly diff" 9 | description = "Shows changed bricks compared to the latest git tag." 10 | 11 | options = [ 12 | command_options.short, 13 | command_options.since, 14 | option( 15 | long_name="bricks", 16 | description="Print changed bricks", 17 | flag=True, 18 | ), 19 | option( 20 | long_name="deps", 21 | description="Print bricks that depend on the changes. Use with --bricks.", 22 | flag=True, 23 | ), 24 | ] 25 | 26 | def handle(self) -> int: 27 | since = self.option("since") 28 | 29 | options = { 30 | "short": self.option("short"), 31 | "bricks": self.option("bricks"), 32 | "deps": self.option("deps"), 33 | } 34 | 35 | commands.diff.run(since, options) 36 | 37 | return 0 38 | -------------------------------------------------------------------------------- /projects/pdm_polylith_workspace/README.md: -------------------------------------------------------------------------------- 1 | # PDM Build Hook for a Polylith workspace 2 | 3 | A plugin for [PDM](https://pdm-project.org) and the Polylith Architecture. 4 | 5 | This build hook is making the virtual environment aware of a Polylith Workspace. 6 | 7 | When running `pdm install` it will add an additional pth file to the virtual environment, 8 | including paths to the `bases` and `components` folders. 9 | 10 | ## Usage 11 | PDM has already a configuration option called `project-dir`, that is meant for defining a custom 12 | path to the Python source code. But it only allows one directory. 13 | 14 | The code in a Polylith workspace is organized into two folders (bases, components), 15 | and that is the reason for using this hook. 16 | 17 | 18 | ## Installation 19 | ``` toml 20 | [build-system] 21 | requires = ["pdm-backend", "pdm-polylith-workspace"] 22 | build-backend = "pdm.backend" 23 | 24 | ``` 25 | 26 | This is only needed in the Polylith workspace `pyproject.toml`, and not in the individual projects. 27 | 28 | ## Polylith documentation 29 | [the Python tools for the Polylith Architecture](https://davidvujic.github.io/python-polylith-docs) 30 | -------------------------------------------------------------------------------- /projects/hatch_polylith_bricks/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "hatch-polylith-bricks" 3 | version = "1.5.3" 4 | description = "Hatch build hook plugin for Polylith" 5 | authors = ['David Vujic'] 6 | homepage = "https://davidvujic.github.io/python-polylith-docs/" 7 | repository = "https://github.com/davidvujic/python-polylith" 8 | license = "MIT" 9 | readme = "README.md" 10 | 11 | packages = [ 12 | {include = "polylith/hatch_hooks", from = "../../bases"}, 13 | {include = "polylith/repo",from = "../../components"}, 14 | {include = "polylith/parsing",from = "../../components"}, 15 | {include = "polylith/hatch",from = "../../components"}, 16 | {include = "polylith/toml",from = "../../components"}, 17 | {include = "polylith/building",from = "../../components"}, 18 | ] 19 | 20 | classifiers = [ 21 | "Framework :: Hatch", 22 | ] 23 | 24 | [tool.poetry.dependencies] 25 | python = "^3.8" 26 | hatchling = "^1.21.0" 27 | tomlkit = "0.*" 28 | 29 | [tool.poetry.plugins.hatch] 30 | polylith-bricks = "hatch_polylith_bricks.polylith.hatch_hooks.hooks" 31 | 32 | [build-system] 33 | requires = ["poetry-core>=1.0.0"] 34 | build-backend = "poetry.core.masonry.api" 35 | -------------------------------------------------------------------------------- /components/polylith/test/core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Union 3 | 4 | from polylith import check, diff, imports 5 | 6 | 7 | def is_test(root: Path, ns: str, path: Path, theme: str) -> bool: 8 | expected = "test" 9 | file_path = path.as_posix() 10 | 11 | if theme == "loose": 12 | test_path = Path(root / f"{expected}/").as_posix() 13 | 14 | return str.startswith(file_path, test_path) 15 | 16 | return f"/{expected}/{ns}" in file_path 17 | 18 | 19 | def get_changed_files(root: Path, tag_name: Union[str, None]) -> List[Path]: 20 | tag = diff.collect.get_latest_tag(root, tag_name) or tag_name 21 | 22 | if not tag: 23 | return [] 24 | 25 | return [root / f for f in diff.collect.get_files(tag)] 26 | 27 | 28 | def get_brick_imports_in_tests( 29 | root: Path, ns: str, theme: str, files: List[Path] 30 | ) -> dict: 31 | matched = {f for f in files if is_test(root, ns, f, theme)} 32 | 33 | listed_imports = [imports.list_imports(m) for m in matched] 34 | 35 | all_imports = {k: v for k, v in enumerate(listed_imports)} 36 | 37 | return check.grouping.extract_brick_imports(all_imports, ns) 38 | -------------------------------------------------------------------------------- /bases/polylith/cli/test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith import commands, configuration, diff, repo 4 | from polylith.cli import options 5 | from typer import Option, Typer 6 | from typing_extensions import Annotated 7 | 8 | app = Typer() 9 | 10 | 11 | @app.command("diff") 12 | def diff_command( 13 | since: Annotated[str, Option(help="Changed since a specific tag.")] = "", 14 | short: Annotated[bool, options.short] = False, 15 | bricks: Annotated[bool, Option(help="Bricks affected by changes in tests")] = False, 16 | projects: Annotated[ 17 | bool, Option(help="Projects affected by changes in tests") 18 | ] = False, 19 | ): 20 | """Shows the Polylith projects and bricks that are affected by changes in tests.""" 21 | root = repo.get_workspace_root(Path.cwd()) 22 | ns = configuration.get_namespace_from_config(root) 23 | 24 | tag = diff.collect.get_latest_tag(root, since) or since 25 | 26 | if not tag: 27 | print("No matching tags or commits found in repository.") 28 | return 29 | 30 | options = {"short": short, "bricks": bricks, "projects": projects} 31 | 32 | commands.test.run(root, ns, tag, options) 33 | -------------------------------------------------------------------------------- /components/polylith/check/grouping.py: -------------------------------------------------------------------------------- 1 | from typing import Set, Union 2 | 3 | 4 | def only_brick_imports(imports: Set[str], top_ns: str) -> Set[str]: 5 | return {i for i in imports if i.startswith(top_ns)} 6 | 7 | 8 | def only_bricks(import_data: dict, top_ns: str) -> dict: 9 | return {k: only_brick_imports(v, top_ns) for k, v in import_data.items()} 10 | 11 | 12 | def brick_import_to_name(brick_import: str) -> Union[str, None]: 13 | parts = brick_import.split(".") 14 | 15 | return parts[1] if len(parts) > 1 else None 16 | 17 | 18 | def only_brick_name(brick_imports: Set[str]) -> Set: 19 | res = {brick_import_to_name(i) for i in brick_imports} 20 | 21 | return {i for i in res if i} 22 | 23 | 24 | def only_brick_names(import_data: dict) -> dict: 25 | return {k: only_brick_name(v) for k, v in import_data.items() if v} 26 | 27 | 28 | def exclude_empty(import_data: dict) -> dict: 29 | return {k: v for k, v in import_data.items() if v} 30 | 31 | 32 | def extract_brick_imports(all_imports: dict, top_ns) -> dict: 33 | with_only_bricks = only_bricks(all_imports, top_ns) 34 | with_only_brick_names = only_brick_names(with_only_bricks) 35 | 36 | return exclude_empty(with_only_brick_names) 37 | -------------------------------------------------------------------------------- /test/components/polylith/deps/test_deps_core.py: -------------------------------------------------------------------------------- 1 | from polylith.deps.core import ( 2 | calculate_brick_deps, 3 | find_bricks_with_circular_dependencies, 4 | ) 5 | 6 | 7 | def test_calculate_brick_deps() -> None: 8 | bricks = {"bases": {"base"}, "components": {"one", "two", "three", "four"}} 9 | 10 | imports = { 11 | "base": {"base", "one"}, 12 | "one": {"one", "four"}, 13 | "two": {"two"}, 14 | "three": {"three", "one"}, 15 | "four": {"four", "two"}, 16 | } 17 | 18 | res = calculate_brick_deps("one", bricks, imports) 19 | 20 | assert sorted(res["used_by"]) == ["base", "three"] 21 | assert sorted(res["uses"]) == ["four"] 22 | 23 | 24 | def test_find_bricks_with_circular_dependencies() -> None: 25 | deps = { 26 | "base": {"used_by": [], "uses": ["one", "four"]}, 27 | "one": {"used_by": ["two", "three"], "uses": ["three"]}, 28 | "two": {"used_by": "three", "uses": []}, 29 | "three": {"used_by": ["one"], "uses": ["one", "two"]}, 30 | "four": {"used_by": ["base"], "uses": ["two"]}, 31 | } 32 | 33 | res = find_bricks_with_circular_dependencies(deps) 34 | 35 | assert res == {"one": {"three"}, "three": {"one"}} 36 | -------------------------------------------------------------------------------- /development/david.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith import ( 4 | alias, 5 | bricks, 6 | check, 7 | commands, 8 | configuration, 9 | development, 10 | diff, 11 | dirs, 12 | distributions, 13 | files, 14 | hatch, 15 | imports, 16 | info, 17 | interface, 18 | libs, 19 | parsing, 20 | pdm, 21 | poetry, 22 | project, 23 | readme, 24 | repo, 25 | reporting, 26 | sync, 27 | test, 28 | toml, 29 | workspace, 30 | ) 31 | 32 | print("hello world") 33 | print(repo.bases_dir) 34 | print(repo.components_dir) 35 | print(repo.projects_dir) 36 | 37 | root = Path.cwd() 38 | ns = configuration.get_namespace_from_config(root) 39 | 40 | tag = diff.collect.get_latest_tag(root, "release") or "" 41 | 42 | changed_files = diff.collect.get_files(tag) 43 | changed_components = diff.collect.get_changed_components(root, changed_files, ns) 44 | changed_bases = diff.collect.get_changed_bases(root, changed_files, ns) 45 | changed_projects = diff.collect.get_changed_projects(root, changed_files) 46 | 47 | projects_data = info.get_bricks_in_projects(root, changed_components, changed_bases, ns) 48 | 49 | bases_data = bricks.base.get_bases_data(root, ns) 50 | -------------------------------------------------------------------------------- /projects/pdm_polylith_bricks/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pdm-polylith-bricks" 3 | version = "1.3.5" 4 | description = "a PDM build hook for Polylith" 5 | authors = ["David Vujic"] 6 | homepage = "https://davidvujic.github.io/python-polylith-docs/" 7 | repository = "https://github.com/davidvujic/python-polylith" 8 | license = "MIT" 9 | readme = "README.md" 10 | 11 | packages = [ 12 | {include = "polylith/pdm_project_hooks", from = "../../bases"}, 13 | {include = "polylith/building",from = "../../components"}, 14 | {include = "polylith/configuration",from = "../../components"}, 15 | {include = "polylith/environment",from = "../../components"}, 16 | {include = "polylith/parsing",from = "../../components"}, 17 | {include = "polylith/pdm",from = "../../components"}, 18 | {include = "polylith/repo",from = "../../components"}, 19 | {include = "polylith/toml",from = "../../components"}, 20 | ] 21 | 22 | [tool.poetry.dependencies] 23 | python = "^3.9" 24 | tomlkit = "0.*" 25 | 26 | [tool.poetry.plugins."pdm.build.hook"] 27 | polylith-bricks = "pdm_polylith_bricks.polylith.pdm_project_hooks.core" 28 | 29 | [build-system] 30 | requires = ["poetry-core>=1.0.0"] 31 | build-backend = "poetry.core.masonry.api" 32 | -------------------------------------------------------------------------------- /bases/polylith/poetry_plugin/plugin.py: -------------------------------------------------------------------------------- 1 | from poetry.console.application import Application 2 | from poetry.plugins.application_plugin import ApplicationPlugin 3 | from polylith.poetry.commands import ( 4 | CheckCommand, 5 | CreateBaseCommand, 6 | CreateComponentCommand, 7 | CreateProjectCommand, 8 | CreateWorkspaceCommand, 9 | DepsCommand, 10 | DiffCommand, 11 | InfoCommand, 12 | LibsCommand, 13 | SyncCommand, 14 | TestDiffCommand, 15 | ) 16 | 17 | commands = [ 18 | CheckCommand, 19 | CreateBaseCommand, 20 | CreateComponentCommand, 21 | CreateProjectCommand, 22 | CreateWorkspaceCommand, 23 | DepsCommand, 24 | DiffCommand, 25 | InfoCommand, 26 | LibsCommand, 27 | SyncCommand, 28 | TestDiffCommand, 29 | ] 30 | 31 | 32 | def register_command(application: Application, command) -> None: 33 | application.command_loader.register_factory(command.name, command) 34 | 35 | 36 | def register_commands(application: Application) -> None: 37 | for command in commands: 38 | register_command(application, command) 39 | 40 | 41 | class PolylithPlugin(ApplicationPlugin): 42 | def activate(self, application: Application): 43 | register_commands(application) 44 | -------------------------------------------------------------------------------- /components/polylith/environment/core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Set 3 | 4 | from polylith import configuration, toml 5 | 6 | 7 | def paths_from_config(ns: str, data: dict) -> Set[str]: 8 | packages = toml.get_project_package_includes(ns, data) 9 | 10 | return {p["from"] for p in packages} 11 | 12 | 13 | def parse_paths(root: Path, theme: str, ns: str, data: dict) -> Set[str]: 14 | defaults = {"bases", "components"} 15 | 16 | paths = defaults if theme == "loose" else paths_from_config(ns, data) 17 | 18 | return {(root / p).as_posix() for p in paths} 19 | 20 | 21 | def write_pth_file(target_dir: Path, paths: Set[str]) -> None: 22 | filepath = target_dir / "polylith_workspace.pth" 23 | 24 | if filepath.exists(): 25 | return 26 | 27 | with open(filepath, "w") as f: 28 | for p in paths: 29 | f.write(f"{p}\n") 30 | 31 | 32 | def add_paths(config_data: dict, target_dir: Path, root: Path) -> None: 33 | theme = configuration.get_theme_from_config(root) 34 | ns = configuration.get_namespace_from_config(root) 35 | 36 | paths = parse_paths(root, theme, ns, config_data) 37 | 38 | if not paths: 39 | return 40 | 41 | write_pth_file(target_dir, paths) 42 | -------------------------------------------------------------------------------- /test/components/polylith/hatch/test_hook.py: -------------------------------------------------------------------------------- 1 | import tomlkit 2 | from polylith.hatch.hooks.bricks import filtered_bricks 3 | 4 | project_toml = """\ 5 | [tool.polylith.bricks] 6 | "../../bases/unittest/one" = "unittest/one" 7 | "../../components/unittest/two" = "unittest/two" 8 | """ 9 | 10 | project_toml_dev_mode = """\ 11 | [tool.hatch.build] 12 | dev-mode-dirs = ["../../components", "../../bases"] 13 | 14 | [tool.polylith.bricks] 15 | "../../bases/unittest/one" = "unittest/one" 16 | "../../components/unittest/two" = "unittest/two" 17 | """ 18 | 19 | 20 | def test_filtered_bricks(): 21 | data = tomlkit.loads(project_toml) 22 | 23 | expected = data["tool"]["polylith"]["bricks"] 24 | 25 | first = filtered_bricks(data, version="standard") 26 | second = filtered_bricks(data, version="editable") 27 | 28 | assert first == expected 29 | assert second == expected 30 | 31 | 32 | def test_filtered_bricks_in_project_with_dev_mode_dirs(): 33 | data = tomlkit.loads(project_toml_dev_mode) 34 | 35 | expected = data["tool"]["polylith"]["bricks"] 36 | 37 | first = filtered_bricks(data, version="standard") 38 | second = filtered_bricks(data, version="editable") 39 | 40 | assert first == expected 41 | assert second == {} 42 | -------------------------------------------------------------------------------- /components/polylith/bricks/component.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List 3 | 4 | from polylith import configuration 5 | from polylith.bricks.brick import create_brick 6 | from polylith.repo import components_dir 7 | from polylith.test import create_test 8 | 9 | 10 | def create_component(path: Path, options: dict) -> None: 11 | extra = {"brick": components_dir} 12 | component_options = {**options, **extra} 13 | 14 | create_brick(path, component_options) 15 | create_test(path, component_options) 16 | 17 | 18 | def is_brick_dir(p: Path) -> bool: 19 | return p.is_dir() and p.name not in {"__pycache__", ".venv", ".mypy_cache"} 20 | 21 | 22 | def get_component_dirs(root: Path, top_dir, ns) -> list: 23 | theme = configuration.get_theme_from_config(root) 24 | dirs = top_dir if theme == "tdd" else f"{top_dir}/{ns}" 25 | 26 | component_dir = root / dirs 27 | 28 | if not component_dir.exists(): 29 | return [] 30 | 31 | return [f for f in component_dir.iterdir() if is_brick_dir(f)] 32 | 33 | 34 | def get_components_data( 35 | root: Path, ns: str, top_dir: str = components_dir 36 | ) -> List[dict]: 37 | dirs = get_component_dirs(root, top_dir, ns) 38 | 39 | return [{"name": d.name} for d in dirs] 40 | -------------------------------------------------------------------------------- /projects/pdm_polylith_workspace/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pdm-polylith-workspace" 3 | version = "1.3.5" 4 | description = "a PDM build hook for a Polylith workspace" 5 | homepage = "https://davidvujic.github.io/python-polylith-docs/" 6 | repository = "https://github.com/davidvujic/python-polylith" 7 | authors = ["David Vujic"] 8 | license = "MIT" 9 | readme = "README.md" 10 | 11 | packages = [ 12 | {include = "polylith/pdm_workspace_hooks", from = "../../bases"}, 13 | {include = "polylith/building",from = "../../components"}, 14 | {include = "polylith/environment",from = "../../components"}, 15 | {include = "polylith/configuration",from = "../../components"}, 16 | {include = "polylith/parsing",from = "../../components"}, 17 | {include = "polylith/pdm",from = "../../components"}, 18 | {include = "polylith/repo",from = "../../components"}, 19 | {include = "polylith/toml",from = "../../components"}, 20 | ] 21 | 22 | [tool.poetry.dependencies] 23 | python = "^3.9" 24 | tomlkit = "0.*" 25 | 26 | [tool.poetry.plugins."pdm.build.hook"] 27 | polylith-workspace = "pdm_polylith_workspace.polylith.pdm_workspace_hooks.core" 28 | 29 | 30 | [build-system] 31 | requires = ["poetry-core>=1.0.0"] 32 | build-backend = "poetry.core.masonry.api" 33 | -------------------------------------------------------------------------------- /components/polylith/poetry/commands/create_project.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from cleo.helpers import option 4 | from poetry.console.commands.command import Command 5 | from polylith import interactive, project 6 | from polylith.commands.create import create 7 | 8 | command_name = "poly create project" 9 | 10 | 11 | def create_project(root: Path, options: dict): 12 | package = options["package"] 13 | desc = options["description"] or "" 14 | 15 | template = project.get_project_template(root) 16 | project.create_project(root, template, package, desc) 17 | 18 | 19 | class CreateProjectCommand(Command): 20 | name = command_name 21 | description = "Creates a Polylith project." 22 | 23 | options = [ 24 | option("name", None, "Name of the project.", flag=False), 25 | option( 26 | "description", 27 | None, 28 | "Description of the project.", 29 | flag=False, 30 | value_required=False, 31 | ), 32 | ] 33 | 34 | def handle(self) -> int: 35 | name = self.option("name") 36 | description = self.option("description") 37 | 38 | create(name, description, create_project) 39 | 40 | interactive.project.run(name) 41 | 42 | return 0 43 | -------------------------------------------------------------------------------- /test/components/polylith/distributions/test_collect.py: -------------------------------------------------------------------------------- 1 | from polylith.distributions import collect 2 | 3 | 4 | def test_parse_third_party_library_name(): 5 | fake_project_deps = { 6 | "items": { 7 | "python": "^3.10", 8 | "fastapi": "^0.110.0", 9 | "uvicorn[standard]": "^0.27.1", 10 | "python-jose[cryptography]": "^3.3.0", 11 | "hello[world, something]": "^3.3.0", 12 | }, 13 | "source": "pyproject.toml", 14 | } 15 | 16 | expected = { 17 | "python", 18 | "fastapi", 19 | "uvicorn", 20 | "standard", 21 | "python-jose", 22 | "cryptography", 23 | "hello", 24 | "world", 25 | "something", 26 | } 27 | 28 | res = collect.extract_library_names(fake_project_deps) 29 | 30 | assert res == expected 31 | 32 | 33 | def test_collect_known_aliases_and_sub_dependencies(): 34 | fake_deps = { 35 | "items": {"typer": "1", "hello-world-library": "2"}, 36 | "source": "unit-test", 37 | } 38 | 39 | fake_alias = ["hello-world-library=hello"] 40 | 41 | res = collect.known_aliases_and_sub_dependencies(fake_deps, fake_alias, {}, False) 42 | 43 | assert "typer" in res 44 | assert "typing-extensions" in res 45 | assert "hello" in res 46 | -------------------------------------------------------------------------------- /components/polylith/readme/readme.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith import repo 4 | 5 | workspace_template = """\ 6 | # A Python Polylith repo 7 | 8 | ## Docs 9 | The official Polylith documentation: 10 | [high-level documentation](https://polylith.gitbook.io/polylith) 11 | 12 | A Python implementation of the Polylith tool: 13 | [python-polylith](https://github.com/DavidVujic/python-polylith) 14 | """ 15 | 16 | brick_template = """\ 17 | # {name} {brick} 18 | 19 | {description} 20 | """ 21 | 22 | 23 | def create_readme(path: Path, template: str, **kwargs) -> None: 24 | fullpath = path / repo.readme_file 25 | 26 | if fullpath.exists(): 27 | return 28 | 29 | with fullpath.open("w", encoding="utf-8") as f: 30 | f.write(template.format(**kwargs)) 31 | 32 | 33 | def create_workspace_readme(path: Path, namespace: str) -> None: 34 | create_readme(path, workspace_template, namespace=namespace) 35 | 36 | 37 | def create_brick_readme(path: Path, options: dict) -> None: 38 | brick = options["brick"] 39 | package = options["package"] 40 | description = options["description"] 41 | 42 | b = "component" if brick in repo.components_dir else "base" 43 | 44 | create_readme( 45 | path, brick_template, name=package, brick=b, description=description or "" 46 | ) 47 | -------------------------------------------------------------------------------- /components/polylith/poetry/commands/deps.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from cleo.helpers import option 4 | from poetry.console.commands.command import Command 5 | from polylith import commands, configuration, repo 6 | from polylith.poetry.commands import command_options 7 | 8 | 9 | class DepsCommand(Command): 10 | name = "poly deps" 11 | description = "Visualize the dependencies between bricks." 12 | 13 | options = [ 14 | option( 15 | long_name="brick", 16 | description="Shows dependencies for selected brick", 17 | flag=False, 18 | ), 19 | command_options.save, 20 | ] 21 | 22 | def handle(self) -> int: 23 | directory = self.option("directory") 24 | brick = self.option("brick") 25 | save = self.option("save") 26 | 27 | root = repo.get_workspace_root(Path.cwd()) 28 | ns = configuration.get_namespace_from_config(root) 29 | 30 | dir_path = Path(directory).as_posix() if directory else None 31 | output = configuration.get_output_dir(root, "deps") if save else None 32 | 33 | options = { 34 | "directory": dir_path, 35 | "brick": brick, 36 | "save": save, 37 | "output": output, 38 | } 39 | commands.deps.run(root, ns, options) 40 | 41 | return 0 42 | -------------------------------------------------------------------------------- /components/polylith/project/create.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import tomlkit 4 | from polylith import configuration, repo 5 | from polylith.dirs import create_dir 6 | from polylith.repo import projects_dir 7 | 8 | 9 | def create_project_toml(template: str, template_data: dict) -> tomlkit.TOMLDocument: 10 | content = template.format(**template_data) 11 | 12 | return tomlkit.loads(content) 13 | 14 | 15 | def create_project(path: Path, template: str, name: str, description: str) -> None: 16 | d = create_dir(path, f"{projects_dir}/{name}") 17 | 18 | authors = repo.get_authors(path) 19 | python_version = repo.get_python_version(path) 20 | 21 | description_field = f'description = "{description}"' if description else "" 22 | authors_field = f"authors = {authors}" if authors else "" 23 | 24 | namespace = configuration.get_namespace_from_config(path) 25 | 26 | project_toml = create_project_toml( 27 | template, 28 | { 29 | "name": name, 30 | "description": description_field, 31 | "authors": authors_field, 32 | "python_version": python_version, 33 | "namespace": namespace, 34 | }, 35 | ) 36 | 37 | fullpath = d / repo.default_toml 38 | 39 | with fullpath.open("w", encoding="utf-8") as f: 40 | f.write(tomlkit.dumps(project_toml)) 41 | -------------------------------------------------------------------------------- /components/polylith/workspace/create.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import tomlkit 4 | from polylith import readme, repo 5 | from polylith.development import create_development 6 | from polylith.dirs import create_dir 7 | 8 | template = """\ 9 | [tool.polylith] 10 | namespace = "{namespace}" 11 | git_tag_pattern = "stable-*" 12 | 13 | [tool.polylith.structure] 14 | theme = "{theme}" 15 | 16 | [tool.polylith.tag.patterns] 17 | stable = "stable-*" 18 | release = "v[0-9]*" 19 | 20 | [tool.polylith.resources] 21 | brick_docs_enabled = false 22 | 23 | [tool.polylith.test] 24 | enabled = true 25 | """ 26 | 27 | 28 | def create_workspace_config(path: Path, namespace: str, theme: str) -> None: 29 | formatted = template.format(namespace=namespace, theme=theme) 30 | content: dict = tomlkit.loads(formatted) 31 | 32 | fullpath = path / repo.workspace_file 33 | 34 | with fullpath.open("w", encoding="utf-8") as f: 35 | f.write(tomlkit.dumps(content)) 36 | 37 | 38 | def create_workspace(path: Path, namespace: str, theme: str) -> None: 39 | create_dir(path, repo.bases_dir, keep=True) 40 | create_dir(path, repo.components_dir, keep=True) 41 | create_dir(path, repo.projects_dir, keep=True) 42 | 43 | create_development(path, keep=True) 44 | 45 | create_workspace_config(path, namespace, theme) 46 | 47 | readme.create_workspace_readme(path, namespace) 48 | -------------------------------------------------------------------------------- /components/polylith/output/core.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | from io import StringIO 3 | from pathlib import Path 4 | from typing import Tuple, cast 5 | 6 | from polylith.reporting import theme 7 | from rich.console import Console 8 | from rich.table import Table 9 | 10 | replacements = {"\u2714": "X", "\U0001F448": "<-", "\U0001F449": "->"} 11 | 12 | 13 | def replace_char(data: str, pair: Tuple[str, str]) -> str: 14 | return str.replace(data, *pair) 15 | 16 | 17 | def adjust(data: str) -> str: 18 | return reduce(replace_char, replacements.items(), data) 19 | 20 | 21 | def write_to_file(data: str, options: dict, command: str) -> None: 22 | adjusted = adjust(data) 23 | 24 | output = options["output"] 25 | fullpath = f"{output}/{command}.txt" 26 | 27 | Path(output).mkdir(parents=True, exist_ok=True) 28 | Path(fullpath).write_text(adjusted) 29 | 30 | 31 | def save_recorded(console: Console, options: dict, command: str) -> None: 32 | exported = console.export_text() 33 | 34 | write_to_file(exported, options, command) 35 | 36 | 37 | def save(table: Table, options: dict, command: str) -> None: 38 | console = Console(theme=theme.poly_theme, width=1024, file=StringIO()) 39 | 40 | console.print(table, overflow="ellipsis") 41 | 42 | f = cast(StringIO, console.file) 43 | exported = f.getvalue() 44 | 45 | write_to_file(exported, options, command) 46 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## How Has This Been Tested? 11 | 12 | 13 | 14 | 15 | ## Types of changes 16 | 17 | - [ ] Bug fix (non-breaking change which fixes an issue) 18 | - [ ] New feature (non-breaking change which adds functionality) 19 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 20 | 21 | ## Checklist: 22 | 23 | 24 | - [ ] I have read the [code of conduct](https://github.com/davidvujic/python-polylith/blob/master/CODE-OF-CONDUCT.md). 25 | - [ ] I have read the [contributing guide](https://github.com/davidvujic/python-polylith/blob/master/CONTRIBUTING.md). 26 | - [ ] I have updated the documentation accordingly (if applicable). 27 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | 4 | import pytest 5 | from polylith.bricks.base import create_base 6 | 7 | 8 | @pytest.fixture(scope="function") 9 | def handle_workspace_files(): 10 | """Creates a temporary directory with a valid workspace file and removes the directory in tear-down. 11 | 12 | Yields: 13 | Path: The temp directory path 14 | """ 15 | temp_dir = Path("test/temp") 16 | temp_dir.mkdir(parents=True, exist_ok=True) 17 | workspace_file = temp_dir / "workspace.toml" 18 | workspace_file.touch() 19 | source_file = Path("test/test_data/workspace.toml") 20 | workspace_file.write_text(source_file.read_text()) 21 | yield temp_dir 22 | shutil.rmtree(temp_dir) 23 | 24 | 25 | @pytest.fixture(scope="function") 26 | def create_test_base(handle_workspace_files): 27 | """Uses the temp directory and creates bases for testing. 28 | 29 | Args: 30 | handle_workspace_files (fixture): Sets up the needed temp directory. 31 | 32 | Yields: 33 | Path: The temp directory path, to be passed to the function under test. 34 | 35 | Note: 36 | The handle_workspace_files fixture will clean up at tear-down. 37 | """ 38 | 39 | options = { 40 | "namespace": "test_namespace", 41 | "package": "test_package", 42 | "description": "test desc", 43 | "modulename": "core", 44 | } 45 | create_base(path=handle_workspace_files, options=options) 46 | yield handle_workspace_files 47 | -------------------------------------------------------------------------------- /components/polylith/building/core.py: -------------------------------------------------------------------------------- 1 | import operator 2 | import shutil 3 | from functools import reduce 4 | from pathlib import Path 5 | from typing import List 6 | 7 | from polylith import parsing 8 | 9 | 10 | def copy_bricks_as_is(bricks: dict, build_dir: Path) -> None: 11 | for source, brick in bricks.items(): 12 | parsing.copy_brick(source, brick, build_dir) 13 | 14 | 15 | def copy_and_rewrite(source: str, brick: str, options: dict) -> List[str]: 16 | work_dir = options["work_dir"] 17 | build_dir = options["build_dir"] 18 | top_ns = options["top_ns"] 19 | ns = options["ns"] 20 | 21 | path = parsing.copy_brick(source, brick, work_dir) 22 | rewritten = parsing.rewrite_modules(path, ns, top_ns) 23 | 24 | destination_dir = build_dir / top_ns 25 | parsing.copy_brick(path.as_posix(), brick, destination_dir) 26 | 27 | return rewritten 28 | 29 | 30 | def copy_and_rewrite_bricks( 31 | bricks: dict, top_ns: str, work_dir: Path, build_dir: Path 32 | ) -> List[str]: 33 | ns = parsing.parse_brick_namespace_from_path(bricks) 34 | 35 | options = {"ns": ns, "top_ns": top_ns, "work_dir": work_dir, "build_dir": build_dir} 36 | 37 | res = [copy_and_rewrite(source, brick, options) for source, brick in bricks.items()] 38 | flattened: List[str] = reduce(operator.iadd, res, []) 39 | 40 | return sorted(flattened) 41 | 42 | 43 | def cleanup(work_dir: Path) -> None: 44 | if not work_dir.exists() or not work_dir.is_dir(): 45 | return 46 | 47 | shutil.rmtree(work_dir.as_posix()) 48 | -------------------------------------------------------------------------------- /components/polylith/poetry/commands/test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from cleo.helpers import option 4 | from poetry.console.commands.command import Command 5 | from polylith import commands, configuration, diff, repo 6 | from polylith.poetry.commands import command_options 7 | 8 | 9 | class TestDiffCommand(Command): 10 | name = "poly test diff" 11 | description = ( 12 | "Shows the Polylith projects and bricks that are affected by changes in tests." 13 | ) 14 | 15 | options = [ 16 | command_options.short, 17 | command_options.since, 18 | option( 19 | long_name="bricks", 20 | description="Bricks affected by changes in tests", 21 | flag=True, 22 | ), 23 | option( 24 | long_name="projects", 25 | description="Projects affected by changes in tests", 26 | flag=True, 27 | ), 28 | ] 29 | 30 | def handle(self) -> int: 31 | since = self.option("since") 32 | 33 | options = { 34 | "short": self.option("short"), 35 | "bricks": self.option("bricks"), 36 | "projects": self.option("projects"), 37 | } 38 | 39 | root = repo.get_workspace_root(Path.cwd()) 40 | ns = configuration.get_namespace_from_config(root) 41 | 42 | tag = diff.collect.get_latest_tag(root, since) or since 43 | 44 | if tag: 45 | commands.test.run(root, ns, tag, options) 46 | else: 47 | self.line("No matching tags or commits found in repository.") 48 | 49 | return 0 50 | -------------------------------------------------------------------------------- /development/README.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | 4 | ## Build and install local version 5 | Make sure to uninstall the properly installed version you have via Poetry: 6 | 7 | ``` shell 8 | poetry self remove poetry-polylith-plugin 9 | ``` 10 | 11 | Build a wheel from your local folder: 12 | ``` shell 13 | poetry build-project --directory projects/poetry_polylith_plugin 14 | ``` 15 | 16 | Install into the Poetry virtual environment (Mac OS X), with pip: 17 | ``` shell 18 | ~/Library/Application\ Support/pypoetry/venv/bin/pip install projects/poetry_polylith_plugin/dist/poetry_polylith_plugin--py3-none-any.whl 19 | ``` 20 | 21 | When done testing, don't forget to uninstall the local test version: 22 | ``` shell 23 | ~/Library/Application\ Support/pypoetry/venv/bin/pip uninstall poetry-polylith-plugin 24 | ``` 25 | 26 | ## Packaging notes 27 | Developer notes about how to package the artifacts, using custom top namespaces. 28 | 29 | The Poetry plugin: 30 | ``` shell 31 | poetry build-project --directory projects/poetry_polylith_plugin 32 | ``` 33 | 34 | The CLI: 35 | ``` shell 36 | poetry build-project --directory projects/polylith_cli --with-top-namespace polylith_cli 37 | ``` 38 | 39 | The Hatch build hook: 40 | ``` shell 41 | poetry build-project --directory projects/hatch_polylith_bricks --with-top-namespace hatch_polylith_bricks 42 | ``` 43 | 44 | The PDM project build hook: 45 | ``` shell 46 | poetry build-project --directory projects/pdm_polylith_bricks --with-top-namespace pdm_polylith_bricks 47 | ``` 48 | 49 | The PDM Workspace build hook: 50 | ``` shell 51 | poetry build-project --directory projects/pdm_polylith_workspace --with-top-namespace pdm_polylith_workspace 52 | ``` 53 | 54 | -------------------------------------------------------------------------------- /projects/poetry_polylith_plugin/README.md: -------------------------------------------------------------------------------- 1 | # Poetry Polylith Plugin 2 | 3 | This is a Python `Poetry` plugin, adding CLI support for the Polylith Architecture. 4 | 5 | ## Documentation 6 | Have a look at the [documentation](https://davidvujic.github.io/python-polylith-docs/). 7 | You will find installation, setup, usage guides and more. 8 | 9 | ## Quick start 10 | 11 | With the `Poetry` version 1.2 or later installed, you can add plugins. 12 | 13 | 14 | Make sure that you have `Poetry` 1.2 or later installed. 15 | 16 | Add the [Multiproject](https://github.com/DavidVujic/poetry-multiproject-plugin) plugin, that will enable the very important __workspace__ support (i.e. relative package includes) to Poetry. 17 | ``` shell 18 | poetry self add poetry-multiproject-plugin 19 | ``` 20 | 21 | Add the Polylith plugin: 22 | ``` shell 23 | poetry self add poetry-polylith-plugin 24 | ``` 25 | 26 | Create a directory for your code, initialize it with __git__ and create a basic __Poetry__ setup: 27 | 28 | ``` shell 29 | git init 30 | 31 | poetry init 32 | ``` 33 | 34 | Next: create a Polylith workspace, with a basic Polylith folder structure. 35 | 36 | ``` shell 37 | poetry poly create workspace --name my_namespace --theme loose 38 | ``` 39 | 40 | Time to start coding. Add components, bases and projects: 41 | 42 | ``` shell 43 | poetry poly create component --name my_component 44 | 45 | poetry poly create base --name my_example_endpoint 46 | 47 | poetry poly create project --name my_example_project 48 | ``` 49 | 50 | For details, have a look at the [documentation](https://davidvujic.github.io/python-polylith-docs/). 51 | There, you will find guides for setup, migration, packaging, available commands, code examples and more. 52 | -------------------------------------------------------------------------------- /test/components/polylith/bricks/test_base.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from polylith.bricks.base import create_base, get_bases_data 5 | 6 | create_base_params = [ 7 | ( 8 | "1. Creates expected directories and files", 9 | set(["bases", "test"]), 10 | set( 11 | [ 12 | Path("test/temp/bases/test_namespace/test_package/core.py"), 13 | Path("test/temp/test/bases/test_namespace/test_package/test_core.py"), 14 | ] 15 | ), 16 | ) 17 | ] 18 | create_base_ids = [x[0] for x in create_base_params] 19 | 20 | 21 | @pytest.mark.parametrize( 22 | "id, expected_dirs, expected_dir_structure", 23 | create_base_params, 24 | ids=create_base_ids, 25 | ) 26 | def test_create_base(handle_workspace_files, id, expected_dirs, expected_dir_structure): 27 | options = { 28 | "namespace": "test_namespace", 29 | "package": "test_package", 30 | "description": "test desc", 31 | "modulename": "core", 32 | } 33 | create_base(path=handle_workspace_files, options=options) 34 | 35 | results = [ 36 | x for x in handle_workspace_files.iterdir() if x.name != "workspace.toml" 37 | ] 38 | 39 | assert all([item.is_dir() for item in results if item in expected_dirs]) 40 | assert ( 41 | set([item.name for item in results]).intersection(expected_dirs) 42 | == expected_dirs 43 | ) 44 | assert all([item.exists for item in expected_dir_structure]) 45 | 46 | 47 | def test_get_bases_data_valid_with_test_file_structure(create_test_base): 48 | result = get_bases_data(create_test_base, "test_namespace") 49 | assert result == [{"name": "test_package"}] 50 | -------------------------------------------------------------------------------- /test/test_data/piptools: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --generate-hashes --output-file=projects/my_fastapi_project/requirements.txt projects/my_fastapi_project/pyproject.toml 6 | # 7 | annotated-types==0.7.0 \ 8 | --hash=sha256:111 \ 9 | --hash=sha256:111 10 | # via pydantic 11 | anyio==4.4.0 \ 12 | --hash=sha256:111 \ 13 | --hash=sha256:111 14 | # via starlette 15 | click==8.1.7 \ 16 | --hash=sha256:111 \ 17 | --hash=sha256:111 18 | # via uvicorn 19 | fastapi==0.109.2 \ 20 | --hash=sha256:111 \ 21 | --hash=sha256:1111 22 | # via my_fastapi_project (projects/my_fastapi_project/pyproject.toml) 23 | h11==0.14.0 \ 24 | --hash=sha256:111 \ 25 | --hash=sha256:111 26 | # via uvicorn 27 | idna==3.7 \ 28 | --hash=sha256:111 \ 29 | --hash=sha256:111 30 | # via anyio 31 | pydantic==2.7.4 \ 32 | --hash=sha256:111 \ 33 | --hash=sha256:111 34 | # via fastapi 35 | pydantic-core==2.18.4 \ 36 | --hash=sha256:111 \ 37 | --hash=sha256:111 \ 38 | --hash=sha256:111 \ 39 | --hash=sha256:111 \ 40 | --hash=sha256:111 41 | # via pydantic 42 | sniffio==1.3.1 \ 43 | --hash=sha256:111 \ 44 | --hash=sha256:111 45 | # via anyio 46 | starlette==0.36.3 \ 47 | --hash=sha256:111 \ 48 | --hash=sha256:111 49 | # via fastapi 50 | typing-extensions==4.12.2 \ 51 | --hash=sha256:111 \ 52 | --hash=sha256:111 53 | # via 54 | # fastapi 55 | # pydantic 56 | # pydantic-core 57 | uvicorn==0.25.0 \ 58 | --hash=sha256:111 \ 59 | --hash=sha256:111 60 | # via my_fastapi_project (projects/my_fastapi_project/pyproject.toml) 61 | -------------------------------------------------------------------------------- /components/polylith/sync/collect.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Set 3 | 4 | from polylith import check, deps, info 5 | 6 | 7 | def _calculate(root: Path, namespace: str, project_data: dict, bases: Set[str]) -> dict: 8 | components = set(project_data["components"]) 9 | 10 | all_bases = info.get_bases(root, namespace) 11 | all_components = info.get_components(root, namespace) 12 | 13 | brick_imports = deps.get_brick_imports(root, namespace, bases, components) 14 | is_project = info.is_project(project_data) 15 | 16 | if is_project: 17 | brick_diff = check.collect.imports_diff(brick_imports, bases, components) 18 | else: 19 | all_bricks = set().union(all_bases, all_components) 20 | brick_diff = check.collect.diff(all_bricks, bases, components) 21 | 22 | bases_diff = {b for b in brick_diff if b in all_bases} 23 | components_diff = {b for b in brick_diff if b in all_components} 24 | 25 | return { 26 | "name": project_data["name"], 27 | "path": project_data["path"], 28 | "is_project": is_project, 29 | "bases": bases_diff, 30 | "components": components_diff, 31 | "brick_imports": brick_imports, 32 | } 33 | 34 | 35 | def calculate_diff(root: Path, namespace: str, project_data: dict) -> dict: 36 | bases = set(project_data["bases"]) 37 | 38 | return _calculate(root, namespace, project_data, bases) 39 | 40 | 41 | def calculate_needed_bricks( 42 | root: Path, namespace: str, project_data: dict, base: str 43 | ) -> dict: 44 | bases = {base} 45 | 46 | res = _calculate(root, namespace, project_data, bases) 47 | 48 | needed_bases = res["bases"].union(bases) 49 | 50 | return {**res, **{"bases": needed_bases}} 51 | -------------------------------------------------------------------------------- /components/polylith/commands/sync.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Union 3 | 4 | from polylith import info, interactive, sync 5 | 6 | 7 | def is_project_without_bricks(project_data: dict) -> bool: 8 | bases = project_data["bases"] 9 | components = project_data["components"] 10 | 11 | return not bases and not components 12 | 13 | 14 | def choose_base(root: Path, ns: str, project_data: dict) -> Union[str, None]: 15 | possible_bases = info.find_unused_bases(root, ns) 16 | 17 | if not possible_bases: 18 | return None 19 | 20 | return interactive.project.choose_base_for_project( 21 | root, ns, project_data["name"], possible_bases 22 | ) 23 | 24 | 25 | def can_run_interactive_mode(project_data: dict, options: dict) -> bool: 26 | is_quiet = options["quiet"] 27 | 28 | if is_quiet: 29 | return False 30 | 31 | return info.is_project(project_data) and is_project_without_bricks(project_data) 32 | 33 | 34 | def calculate_brick_diff( 35 | root: Path, ns: str, project_data: dict, options: dict 36 | ) -> dict: 37 | if can_run_interactive_mode(project_data, options): 38 | base = choose_base(root, ns, project_data) 39 | 40 | if base: 41 | return sync.calculate_needed_bricks(root, ns, project_data, base) 42 | 43 | return sync.calculate_diff(root, ns, project_data) 44 | 45 | 46 | def run(root: Path, ns: str, project_data: dict, options: dict): 47 | is_quiet = options["quiet"] 48 | is_verbose = options["verbose"] 49 | 50 | diff = calculate_brick_diff(root, ns, project_data, options) 51 | 52 | sync.update_project(root, ns, diff) 53 | 54 | if is_quiet: 55 | return 56 | 57 | sync.report.print_summary(diff) 58 | 59 | if is_verbose: 60 | sync.report.print_brick_imports(diff) 61 | -------------------------------------------------------------------------------- /components/polylith/distributions/collect.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | from typing import Set 3 | 4 | from polylith import alias 5 | from polylith.distributions.core import ( 6 | distributions_packages, 7 | distributions_sub_packages, 8 | get_distributions, 9 | get_packages_distributions, 10 | ) 11 | 12 | 13 | def extract_extras(name: str) -> Set[str]: 14 | chars = ["[", "]"] 15 | replacement = "," 16 | 17 | res = reduce(lambda acc, char: str.replace(acc, char, replacement), chars, name) 18 | 19 | parts = str.split(res, replacement) 20 | 21 | return {str.strip(p) for p in parts if p} 22 | 23 | 24 | def extract_library_names(deps: dict) -> Set[str]: 25 | names = {k for k, _v in deps["items"].items()} 26 | 27 | with_extras = [extract_extras(n) for n in names] 28 | 29 | return set().union(*with_extras) 30 | 31 | 32 | def known_aliases_and_sub_dependencies( 33 | deps: dict, library_alias: list, options: dict, from_lock_file: bool, 34 | ) -> Set[str]: 35 | """Collect known aliases (packages) for third-party libraries. 36 | 37 | When the library origin is not from a lock-file: 38 | collect sub-dependencies and distribution top-namespace for each library, and append to the result. 39 | """ 40 | 41 | third_party_libs = extract_library_names(deps) 42 | 43 | fn = options.get("dists_fn", get_distributions) 44 | dists = fn() 45 | 46 | dist_packages = distributions_packages(dists) 47 | custom_aliases = alias.parse(library_alias) 48 | sub_deps = distributions_sub_packages(dists) if not from_lock_file else {} 49 | 50 | a = alias.pick(dist_packages, third_party_libs) 51 | b = alias.pick(custom_aliases, third_party_libs) 52 | c = alias.pick(sub_deps, third_party_libs) 53 | d = get_packages_distributions(third_party_libs) 54 | 55 | return third_party_libs.union(a, b, c, d) 56 | -------------------------------------------------------------------------------- /test/components/polylith/check/test_report.py: -------------------------------------------------------------------------------- 1 | from polylith.check import report 2 | 3 | 4 | def test_extract_collected_imports() -> None: 5 | ns = "my_top_namespace" 6 | 7 | imports_in_bases = { 8 | "cli": { 9 | f"{ns}.first.thing", 10 | f"{ns}.second.thing", 11 | "tomlkit", 12 | } 13 | } 14 | 15 | imports_in_components = { 16 | "first": { 17 | f"{ns}.third", 18 | }, 19 | "second": { 20 | "functools.reduce", 21 | "httpx", 22 | "io.StringIO", 23 | "pathlib.Path", 24 | }, 25 | "third": {}, 26 | } 27 | 28 | expected = { 29 | "brick_imports": { 30 | "bases": {"cli": {"first", "second"}}, 31 | "components": {"first": {"third"}}, 32 | }, 33 | "third_party_imports": { 34 | "bases": {"cli": {"tomlkit"}}, 35 | "components": {"second": {"httpx"}}, 36 | }, 37 | } 38 | 39 | res = report.extract_collected_imports(ns, imports_in_bases, imports_in_components) 40 | 41 | assert res == expected 42 | 43 | 44 | def test_create_exclude_report() -> None: 45 | collected_excludes = { 46 | "brick_imports": { 47 | "bases": {"cli": {"second"}}, 48 | "components": {"first": {"third"}}, 49 | }, 50 | "third_party_imports": { 51 | "bases": {"cli": {"tomlkit"}}, 52 | "components": {"second": {"httpx"}}, 53 | }, 54 | } 55 | 56 | expected = { 57 | "brick_exclude": {"second", "third"}, 58 | "libs_exclude": {"httpx", "tomlkit"}, 59 | } 60 | 61 | assert report.create_exclude_report(collected_excludes) == expected 62 | 63 | 64 | def test_create_exclude_report_for_no_excluded_patterns() -> None: 65 | expected: dict = {"brick_exclude": set(), "libs_exclude": set()} 66 | 67 | assert report.create_exclude_report({}) == expected 68 | -------------------------------------------------------------------------------- /components/polylith/libs/grouping.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | from typing import Set 4 | 5 | from polylith import configuration 6 | from polylith.imports import extract_top_ns, fetch_all_imports, fetch_excluded_imports 7 | from polylith.libs.stdlib import standard_libs 8 | 9 | 10 | def get_python_version() -> str: 11 | return f"{sys.version_info.major}.{sys.version_info.minor}" 12 | 13 | 14 | def get_latest_standard_libs() -> Set[str]: 15 | values = list(standard_libs.values()) 16 | 17 | return values[-1] 18 | 19 | 20 | def get_standard_libs(python_version: str) -> Set[str]: 21 | libs = standard_libs.get(python_version) 22 | 23 | return libs or get_latest_standard_libs() 24 | 25 | 26 | def exclude_libs(import_data: dict, to_exclude: Set) -> dict: 27 | return {k: v - to_exclude for k, v in import_data.items()} 28 | 29 | 30 | def exclude_empty(import_data: dict) -> dict: 31 | return {k: v for k, v in import_data.items() if v} 32 | 33 | 34 | def extract_third_party_imports(all_imports: dict, top_ns: str) -> dict: 35 | python_version = get_python_version() 36 | std_libs = get_standard_libs(python_version) 37 | 38 | top_level_imports = extract_top_ns(all_imports) 39 | with_third_party = exclude_libs(top_level_imports, std_libs.union({top_ns})) 40 | 41 | return exclude_empty(with_third_party) 42 | 43 | 44 | def get_third_party_imports( 45 | root: Path, paths: Set[Path], project_data: dict 46 | ) -> dict: 47 | top_ns = configuration.get_namespace_from_config(root) 48 | 49 | all_imports = fetch_all_imports(paths) 50 | 51 | third_party = extract_third_party_imports(all_imports, top_ns) 52 | 53 | exclude = project_data["exclude"] 54 | 55 | if not exclude: 56 | return third_party 57 | 58 | excluded = fetch_excluded_imports(paths, exclude) 59 | excluded_third_party = extract_third_party_imports(excluded, top_ns) 60 | 61 | return {k: v for k, v in third_party.items() if k not in excluded_third_party} 62 | -------------------------------------------------------------------------------- /components/polylith/parsing/core.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | import shutil 3 | from pathlib import Path 4 | from typing import List, Set, Union 5 | 6 | from polylith import repo 7 | 8 | default_patterns = { 9 | "*.pyc", 10 | "__pycache__", 11 | ".venv", 12 | "__pypackages__", 13 | ".mypy_cache", 14 | ".ruff_cache", 15 | ".pytest_cache", 16 | "node_modules", 17 | ".git", 18 | ".pixi", 19 | } 20 | 21 | 22 | def is_match(root: Path, pattern: str, name: str, current: Path) -> bool: 23 | path_name = (current / name).relative_to(root).as_posix() 24 | 25 | return any(fnmatch.fnmatch(n, pattern) for n in [path_name, name]) 26 | 27 | 28 | def any_match(root: Path, patterns: set, name: str, current: Path) -> bool: 29 | return any(is_match(root, pattern, name, current) for pattern in patterns) 30 | 31 | 32 | def ignore_paths(patterns: Set[str]): 33 | root = repo.get_workspace_root(Path.cwd()) 34 | 35 | def fn(current_path: str, names: List[str]): 36 | current = Path(current_path).resolve() 37 | 38 | return {name for name in names if any_match(root, patterns, name, current)} 39 | 40 | return fn 41 | 42 | 43 | def copy_tree(source: str, destination: str, patterns: Set[str]) -> Path: 44 | is_paths = any("/" in p for p in patterns) 45 | fn = ignore_paths(patterns) if is_paths else shutil.ignore_patterns(*patterns) 46 | 47 | res = shutil.copytree(source, destination, ignore=fn, dirs_exist_ok=True) 48 | 49 | return Path(res) 50 | 51 | 52 | def copy_brick( 53 | source: str, 54 | brick: str, 55 | destination_dir: Path, 56 | exclude_patterns: Union[set, None] = None, 57 | ) -> Path: 58 | destination = Path(destination_dir / brick).as_posix() 59 | 60 | patterns = set().union(default_patterns, exclude_patterns or set()) 61 | 62 | return copy_tree(source, destination, patterns) 63 | 64 | 65 | def parse_brick_namespace_from_path(bricks: dict) -> str: 66 | parts = {str.split(v, "/")[0] for v in bricks.values()} 67 | 68 | return next(part for part in parts) 69 | -------------------------------------------------------------------------------- /components/polylith/commands/libs.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | from pathlib import Path 3 | from typing import List, Set 4 | 5 | from polylith import distributions 6 | from polylith.libs import is_from_lock_file, report 7 | 8 | 9 | def missing_libs(project_data: dict, imports: dict, options: dict) -> bool: 10 | is_strict = options["strict"] 11 | library_alias = options["alias"] 12 | 13 | name = project_data["name"] 14 | deps = project_data["deps"] 15 | 16 | brick_imports = imports[name] 17 | 18 | from_lock_file = is_from_lock_file(deps) 19 | 20 | libs = distributions.known_aliases_and_sub_dependencies( 21 | deps, library_alias, options, from_lock_file 22 | ) 23 | 24 | return report.print_missing_installed_libs( 25 | brick_imports, 26 | libs, 27 | name, 28 | is_strict or from_lock_file, 29 | ) 30 | 31 | 32 | def flatten_imports(acc: dict, item: dict) -> dict: 33 | bases = item.get("bases", {}) 34 | components = item.get("components", {}) 35 | 36 | return { 37 | "bases": {**acc.get("bases", {}), **bases}, 38 | "components": {**acc.get("components", {}), **components}, 39 | } 40 | 41 | 42 | def run_library_versions( 43 | projects_data: List[dict], all_projects_data: List[dict], options: dict 44 | ) -> None: 45 | development_data = next(p for p in all_projects_data if p["type"] == "development") 46 | filtered_projects_data = [p for p in projects_data if p["type"] != "development"] 47 | 48 | report.print_libs_in_projects(development_data, filtered_projects_data, options) 49 | 50 | 51 | def run( 52 | root: Path, 53 | ns: str, 54 | projects_data: List[dict], 55 | options: dict, 56 | ) -> Set[bool]: 57 | imports = { 58 | p["name"]: report.get_third_party_imports(root, ns, p) for p in projects_data 59 | } 60 | 61 | flattened: dict = reduce(flatten_imports, imports.values(), {}) 62 | 63 | report.print_libs_summary() 64 | report.print_libs_in_bricks(flattened, options) 65 | 66 | return {missing_libs(p, imports, options) for p in projects_data} 67 | -------------------------------------------------------------------------------- /test/components/polylith/libs/test_stdlib.py: -------------------------------------------------------------------------------- 1 | from polylith.libs import stdlib 2 | 3 | 4 | def test_stdlib_extras(): 5 | py = stdlib.standard_libs["3.11"] 6 | 7 | assert "__future__" in py 8 | assert "pkg_resources" in py 9 | 10 | 11 | def test_stdlib_3_9(): 12 | py38 = stdlib.standard_libs["3.8"] 13 | py39 = stdlib.standard_libs["3.9"] 14 | 15 | assert py38.difference(py39) == {"_dummy_thread", "dummy_threading"} 16 | assert py39.difference(py38) == {"graphlib", "zoneinfo"} 17 | 18 | 19 | def test_stdlib_3_10(): 20 | py39 = stdlib.standard_libs["3.9"] 21 | py310 = stdlib.standard_libs["3.10"] 22 | 23 | assert py39.difference(py310) == {"formatter", "parser", "symbol"} 24 | assert py310.difference(py39) == {"idlelib"} 25 | 26 | 27 | def test_stdlib_3_11(): 28 | py310 = stdlib.standard_libs["3.10"] 29 | py311 = stdlib.standard_libs["3.11"] 30 | 31 | assert py310.difference(py311) == {"binhex"} 32 | assert py311.difference(py310) == { 33 | "tomllib", 34 | "_tkinter", 35 | "sitecustomize", 36 | "usercustomize", 37 | } 38 | 39 | 40 | def test_stdlib_3_12(): 41 | py311 = stdlib.standard_libs["3.11"] 42 | py312 = stdlib.standard_libs["3.12"] 43 | 44 | assert py311.difference(py312) == { 45 | "asynchat", 46 | "asyncore", 47 | "distutils", 48 | "imp", 49 | "smtpd", 50 | } 51 | assert py312.difference(py311) == set() 52 | 53 | 54 | def test_stdlib_3_13(): 55 | py312 = stdlib.standard_libs["3.12"] 56 | py313 = stdlib.standard_libs["3.13"] 57 | 58 | assert py312.difference(py313) == { 59 | "aifc", 60 | "audioop", 61 | "cgi", 62 | "cgitb", 63 | "chunk", 64 | "crypt", 65 | "imghdr", 66 | "lib2to3", 67 | "mailcap", 68 | "msilib", 69 | "nis", 70 | "nntplib", 71 | "ossaudiodev", 72 | "pipes", 73 | "sndhdr", 74 | "spwd", 75 | "sunau", 76 | "telnetlib", 77 | "uu", 78 | "xdrlib", 79 | } 80 | 81 | assert py313.difference(py312) == set() 82 | -------------------------------------------------------------------------------- /components/polylith/poetry/commands/check.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from pathlib import Path 3 | 4 | from poetry.console.commands.command import Command 5 | from polylith import commands, configuration, info, repo 6 | from polylith.poetry import internals 7 | from polylith.poetry.commands import command_options 8 | 9 | 10 | class CheckCommand(Command): 11 | name = "poly check" 12 | description = "Validates the Polylith workspace." 13 | 14 | options = [command_options.alias, command_options.strict] 15 | 16 | def merged_project_data(self, project_data: dict) -> dict: 17 | name = project_data["name"] 18 | 19 | try: 20 | return internals.merge_project_data(project_data) 21 | except ValueError as e: 22 | self.line_error(f"{name}: {e}") 23 | return project_data 24 | 25 | def handle(self) -> int: 26 | root = repo.get_workspace_root(Path.cwd()) 27 | dists_fn = partial(internals.distributions, root) 28 | 29 | options = { 30 | "verbose": True if self.option("verbose") else False, 31 | "short": False, 32 | "quiet": True if self.option("quiet") else False, 33 | "strict": True if self.option("strict") else False, 34 | "alias": self.option("alias") or [], 35 | "dists_fn": dists_fn, 36 | } 37 | 38 | directory = self.option("directory") 39 | ns = configuration.get_namespace_from_config(root) 40 | 41 | all_projects_data = info.get_projects_data(root, ns) 42 | only_projects_data = [p for p in all_projects_data if info.is_project(p)] 43 | 44 | projects_data = internals.filter_projects_data( 45 | self.poetry, directory, only_projects_data 46 | ) 47 | 48 | merged_projects_data = [ 49 | self.merged_project_data(data) for data in projects_data 50 | ] 51 | 52 | result = commands.check.run(root, ns, merged_projects_data, options) 53 | 54 | libs_result = commands.check.check_libs_versions( 55 | projects_data, all_projects_data, options 56 | ) 57 | 58 | return 0 if result and libs_result else 1 59 | -------------------------------------------------------------------------------- /components/polylith/poetry/commands/libs.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from pathlib import Path 3 | 4 | from poetry.console.commands.command import Command 5 | from polylith import commands, configuration, info, repo 6 | from polylith.poetry.commands import command_options 7 | from polylith.poetry.internals import ( 8 | distributions, 9 | filter_projects_data, 10 | merge_project_data, 11 | ) 12 | 13 | 14 | class LibsCommand(Command): 15 | name = "poly libs" 16 | description = "Show third-party libraries used in the workspace." 17 | 18 | options = [ 19 | command_options.alias, 20 | command_options.save, 21 | command_options.short, 22 | command_options.strict, 23 | ] 24 | 25 | def merged_project_data(self, project_data: dict) -> dict: 26 | name = project_data["name"] 27 | 28 | try: 29 | return merge_project_data(project_data) 30 | except ValueError as e: 31 | self.line_error(f"{name}: {e}") 32 | return project_data 33 | 34 | def handle(self) -> int: 35 | root = repo.get_workspace_root(Path.cwd()) 36 | dists_fn = partial(distributions, root) 37 | save = self.option("save") 38 | 39 | output = configuration.get_output_dir(root, "libs") if save else None 40 | 41 | options = { 42 | "strict": self.option("strict"), 43 | "alias": self.option("alias"), 44 | "short": self.option("short"), 45 | "save": save, 46 | "output": output, 47 | "dists_fn": dists_fn, 48 | } 49 | 50 | directory = self.option("directory") 51 | ns = configuration.get_namespace_from_config(root) 52 | 53 | all_projects_data = info.get_projects_data(root, ns) 54 | projects_data = filter_projects_data(self.poetry, directory, all_projects_data) 55 | 56 | merged_projects_data = [ 57 | self.merged_project_data(data) for data in projects_data 58 | ] 59 | 60 | results = commands.libs.run(root, ns, merged_projects_data, options) 61 | commands.libs.run_library_versions(projects_data, all_projects_data, options) 62 | 63 | return 0 if all(results) else 1 64 | -------------------------------------------------------------------------------- /components/polylith/commands/deps.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Set 3 | 4 | from polylith import bricks, deps, info 5 | 6 | 7 | def get_imports(root: Path, ns: str, bricks: dict) -> dict: 8 | bases = bricks["bases"] 9 | components = bricks["components"] 10 | brick_imports = deps.get_brick_imports(root, ns, bases, components) 11 | 12 | return {**brick_imports["bases"], **brick_imports["components"]} 13 | 14 | 15 | def pick_name(data: List[dict]) -> Set[str]: 16 | return {b["name"] for b in data} 17 | 18 | 19 | def get_bases(root: Path, ns: str, project_data: dict) -> Set[str]: 20 | if project_data: 21 | return set(project_data.get("bases", [])) 22 | 23 | return pick_name(bricks.get_bases_data(root, ns)) 24 | 25 | 26 | def get_components(root: Path, ns: str, project_data: dict) -> Set[str]: 27 | if project_data: 28 | return set(project_data.get("components", [])) 29 | 30 | return pick_name(bricks.get_components_data(root, ns)) 31 | 32 | 33 | def run(root: Path, ns: str, options: dict): 34 | directory = options.get("directory") 35 | brick = options.get("brick") 36 | 37 | projects_data = info.get_projects_data(root, ns) if directory else [] 38 | project = next((p for p in projects_data if directory in p["path"].as_posix()), {}) 39 | 40 | bricks = { 41 | "bases": get_bases(root, ns, project), 42 | "components": get_components(root, ns, project), 43 | } 44 | 45 | imports = get_imports(root, ns, bricks) 46 | 47 | bricks_deps = { 48 | b: deps.calculate_brick_deps(b, bricks, imports) 49 | for b in set().union(*bricks.values()) 50 | } 51 | 52 | circular_bricks = deps.find_bricks_with_circular_dependencies(bricks_deps) 53 | 54 | if brick and imports.get(brick): 55 | brick_deps = bricks_deps[brick] 56 | circular_deps = circular_bricks.get(brick) 57 | 58 | deps.print_brick_deps(brick, bricks, brick_deps, options) 59 | 60 | if circular_deps: 61 | deps.print_brick_with_circular_deps(brick, circular_deps, bricks) 62 | 63 | return 64 | 65 | deps.print_deps(bricks, imports, options) 66 | deps.print_bricks_with_circular_deps(circular_bricks, bricks) 67 | -------------------------------------------------------------------------------- /bases/polylith/cli/create.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from polylith import interactive, project 4 | from polylith.bricks import base, component 5 | from polylith.commands.create import create 6 | from polylith.workspace.create import create_workspace 7 | from typer import Exit, Option, Typer 8 | from typing_extensions import Annotated 9 | 10 | app = Typer() 11 | 12 | 13 | @app.command("base") 14 | def base_command( 15 | name: Annotated[str, Option(help="Name of the base.")], 16 | description: Annotated[str, Option(help="Description of the base.")] = "", 17 | ): 18 | """Creates a Polylith base.""" 19 | create(name, description, base.create_base) 20 | 21 | 22 | @app.command("component") 23 | def component_command( 24 | name: Annotated[str, Option(help="Name of the component.")], 25 | description: Annotated[str, Option(help="Description of the component.")] = "", 26 | ): 27 | """Creates a Polylith component.""" 28 | create(name, description, component.create_component) 29 | 30 | 31 | def _create_project(root: Path, options: dict): 32 | name = options["package"] 33 | description = options["description"] 34 | 35 | template = project.get_project_template(root) 36 | 37 | if not template: 38 | print("Failed to guess the used Package & Dependency Management") 39 | print( 40 | "Is the root pyproject.toml missing, or are you using a tool not supported by Polylith?" 41 | ) 42 | raise Exit(code=1) 43 | 44 | project.create_project(root, template, name, description or "") 45 | 46 | 47 | @app.command("project") 48 | def project_command( 49 | name: Annotated[str, Option(help="Name of the project.")], 50 | description: Annotated[str, Option(help="Description of the project.")] = "", 51 | ): 52 | """Creates a Polylith project.""" 53 | create(name, description, _create_project) 54 | 55 | interactive.project.run(name) 56 | 57 | 58 | @app.command("workspace") 59 | def workspace_command( 60 | name: Annotated[str, Option(help="Name of the workspace.")], 61 | theme: Annotated[str, Option(help="Workspace theme.")] = "tdd", 62 | ): 63 | """Creates a Polylith workspace in the current directory.""" 64 | path = Path.cwd() 65 | 66 | create_workspace(path, name, theme) 67 | -------------------------------------------------------------------------------- /components/polylith/commands/test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Set, Tuple, Union 3 | 4 | from polylith import bricks, configuration, diff, info, test 5 | 6 | 7 | def get_imported_bricks_in_tests( 8 | root: Path, ns: str, tag_name: Union[str, None], theme: str 9 | ) -> Set[str]: 10 | files = test.get_changed_files(root, tag_name) 11 | brick_imports = test.get_brick_imports_in_tests(root, ns, theme, files) 12 | 13 | return set().union(*brick_imports.values()) 14 | 15 | 16 | def extract_brick_names(bricks_data: List[dict], imported_bricks: Set[str]) -> Set[str]: 17 | return {v for b in bricks_data for v in b.values() if v in imported_bricks} 18 | 19 | 20 | def get_affected_bricks( 21 | root: Path, ns: str, tag_name: Union[str, None], theme: str 22 | ) -> Tuple[Set[str], Set[str]]: 23 | found = get_imported_bricks_in_tests(root, ns, tag_name, theme) 24 | 25 | bases = extract_brick_names(bricks.get_bases_data(root, ns), found) 26 | components = extract_brick_names(bricks.get_components_data(root, ns), found) 27 | 28 | return bases, components 29 | 30 | 31 | def get_affected_projects( 32 | root: Path, ns: str, bases: Set[str], components: Set[str] 33 | ) -> List[dict]: 34 | projects_data = [p for p in info.get_projects_data(root, ns) if info.is_project(p)] 35 | 36 | names = diff.collect.get_projects_affected_by_changes( 37 | projects_data, [], list(bases), list(components) 38 | ) 39 | 40 | return [p for p in projects_data if p["path"].name in names] 41 | 42 | 43 | def run(root: Path, ns: str, tag: str, options: dict) -> None: 44 | theme = configuration.get_theme_from_config(root) 45 | 46 | bases, components = get_affected_bricks(root, ns, tag, theme) 47 | projects_data = get_affected_projects(root, ns, bases, components) 48 | 49 | if options.get("bricks"): 50 | test.report.print_detected_changes_affecting_bricks(bases, components, options) 51 | return 52 | 53 | if options.get("projects"): 54 | test.report.print_projects_affected_by_changes(projects_data, options) 55 | return 56 | 57 | test.report.print_report_summary(projects_data, bases, components, tag) 58 | test.report.print_test_report(projects_data, bases, components, options) 59 | -------------------------------------------------------------------------------- /components/polylith/poetry/internals.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from pathlib import Path 3 | from typing import List, Union 4 | 5 | from poetry.factory import Factory 6 | from poetry.poetry import Poetry 7 | from poetry.utils.env import EnvManager 8 | from polylith import project 9 | 10 | 11 | def get_project_poetry(path: Path) -> Poetry: 12 | return Factory().create_poetry(path) 13 | 14 | 15 | @lru_cache 16 | def distributions(path: Path) -> list: 17 | """Get distributions from the current Poetry project context. 18 | 19 | When running code within Poetry, the current environment is the one Poetry uses and 20 | not the environment in the current project or workspace. 21 | 22 | Querying importlib.metadata will fail to find the libraries added to the workspace. 23 | 24 | This function uses the Poetry internals to fetch the distributions, 25 | that internally queries the metadata based on the current path. 26 | """ 27 | project_poetry = get_project_poetry(path) 28 | 29 | env = EnvManager(project_poetry).get() 30 | 31 | return list(env.site_packages.distributions()) 32 | 33 | 34 | def find_third_party_libs(path: Path) -> dict: 35 | project_poetry = get_project_poetry(path) 36 | 37 | if not project_poetry.locker.is_locked(): 38 | raise ValueError("poetry.lock not found. Run `poetry lock` to create it.") 39 | 40 | packages = project_poetry.locker.locked_repository().packages 41 | 42 | return {p.name: str(p.version) for p in packages} 43 | 44 | 45 | def merge_project_data(project_data: dict) -> dict: 46 | path = project_data["path"] 47 | 48 | third_party_libs = find_third_party_libs(path) 49 | return { 50 | **project_data, 51 | **{"deps": {"items": third_party_libs, "source": "poetry.lock"}}, 52 | } 53 | 54 | 55 | def filter_projects_data( 56 | poetry: Poetry, directory: Union[str, None], projects_data: List[dict] 57 | ) -> List[dict]: 58 | if not directory: 59 | return projects_data 60 | 61 | project_name = project.get_project_name(poetry.pyproject.data) 62 | 63 | data = next((p for p in projects_data if p["name"] == project_name), None) 64 | 65 | if not data: 66 | raise ValueError(f"Didn't find project in {directory}") 67 | 68 | return [data] 69 | -------------------------------------------------------------------------------- /components/polylith/test/report.py: -------------------------------------------------------------------------------- 1 | from typing import List, Set 2 | 3 | from polylith import info 4 | from polylith.reporting import theme 5 | from rich.console import Console 6 | from rich.padding import Padding 7 | 8 | 9 | def print_report_summary( 10 | projects_data: List[dict], bases: Set[str], components: Set[str], tag: str 11 | ) -> None: 12 | console = Console(theme=theme.poly_theme) 13 | 14 | number_of_projects = len(projects_data) 15 | number_of_components = len(components) 16 | number_of_bases = len(bases) 17 | 18 | console.print( 19 | Padding( 20 | "[data]Projects and bricks affected by changes in tests[/]", (1, 0, 0, 0) 21 | ) 22 | ) 23 | console.print(Padding(f"[data]Test diff based on {tag}[/]", (0, 0, 1, 0))) 24 | 25 | console.print(f"[proj]Affected projects[/]: [data]{number_of_projects}[/]") 26 | console.print(f"[comp]Affected components[/]: [data]{number_of_components}[/]") 27 | console.print(f"[base]Affected bases[/]: [data]{number_of_bases}[/]") 28 | 29 | 30 | def print_detected_changes(changes: List[str], options: dict): 31 | short = options.get("short", False) 32 | 33 | if not changes: 34 | return 35 | 36 | console = Console(theme=theme.poly_theme) 37 | 38 | if short: 39 | console.out(",".join(changes)) 40 | return 41 | 42 | for item in changes: 43 | console.print(f"[data]:gear: Changes affecting [/][data]{item}[/]") 44 | 45 | 46 | def print_projects_affected_by_changes( 47 | projects_data: List[dict], options: dict 48 | ) -> None: 49 | sorted_projects = [p["path"].name for p in projects_data] 50 | 51 | print_detected_changes(sorted_projects, options) 52 | 53 | 54 | def print_detected_changes_affecting_bricks( 55 | bases: Set[str], components: Set[str], options: dict 56 | ) -> None: 57 | bricks = bases.union(components) 58 | changes = sorted(list(bricks)) 59 | 60 | print_detected_changes(changes, options) 61 | 62 | 63 | def print_test_report( 64 | projects_data: List[dict], bases: Set[str], components: Set[str], options: dict 65 | ) -> None: 66 | b = sorted(list(bases)) 67 | c = sorted(list(components)) 68 | 69 | if not b and not c: 70 | return 71 | 72 | info.print_bricks_in_projects(projects_data, b, c, options) 73 | -------------------------------------------------------------------------------- /components/polylith/info/collect.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Set 3 | 4 | from polylith.bricks import base, component 5 | from polylith.info.report import is_project 6 | from polylith.project import get_packages_for_projects, parse_package_paths 7 | 8 | 9 | def get_matching_bricks( 10 | paths: List[Path], bricks: List[str], namespace: str 11 | ) -> List[str]: 12 | paths_in_namespace = (p.name for p in paths if p.parent.name == namespace) 13 | 14 | res = set(bricks).intersection(paths_in_namespace) 15 | 16 | return sorted(list(res)) 17 | 18 | 19 | def get_project_bricks(project_packages: List[dict], components, bases, namespace: str): 20 | paths = parse_package_paths(project_packages) 21 | 22 | components_in_project = get_matching_bricks(paths, components, namespace) 23 | bases_in_project = get_matching_bricks(paths, bases, namespace) 24 | 25 | return {"components": components_in_project, "bases": bases_in_project} 26 | 27 | 28 | def get_components(root: Path, namespace: str) -> List[str]: 29 | return [c["name"] for c in component.get_components_data(root, namespace)] 30 | 31 | 32 | def get_bases(root: Path, namespace: str) -> List[str]: 33 | return [b["name"] for b in base.get_bases_data(root, namespace)] 34 | 35 | 36 | def get_bricks_in_projects( 37 | root: Path, components: List[str], bases: List[str], namespace: str 38 | ) -> List[dict]: 39 | packages_for_projects = get_packages_for_projects(root) 40 | 41 | res = [ 42 | { 43 | **{k: v for k, v in p.items() if k not in {"packages"}}, 44 | **get_project_bricks(p["packages"], components, bases, namespace), 45 | } 46 | for p in packages_for_projects 47 | ] 48 | 49 | return res 50 | 51 | 52 | def get_projects_data(root: Path, ns: str) -> List[dict]: 53 | bases = get_bases(root, ns) 54 | components = get_components(root, ns) 55 | 56 | return get_bricks_in_projects(root, components, bases, ns) 57 | 58 | 59 | def find_unused_bases(root: Path, ns: str) -> Set[str]: 60 | projects_data = get_projects_data(root, ns) 61 | 62 | bases = get_bases(root, ns) 63 | 64 | bases_per_project = [p["bases"] for p in projects_data if is_project(p)] 65 | bases_in_projects = set().union(*bases_per_project) 66 | 67 | return set(bases).difference(bases_in_projects) 68 | -------------------------------------------------------------------------------- /test/components/polylith/repo/test_repo_get.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import tomlkit 4 | from polylith import repo 5 | 6 | poetry_toml = """\ 7 | [tool.poetry] 8 | name = "hello world" 9 | version = "0.1.0" 10 | description = "describing the project" 11 | authors = {authors} 12 | license = "MIT" 13 | 14 | packages = [] 15 | 16 | [tool.poetry.dependencies] 17 | python = "^3.8" 18 | 19 | [tool.poetry.group.dev.dependencies] 20 | 21 | [build-system] 22 | requires = ["poetry-core>=1.0.0"] 23 | build-backend = "poetry.core.masonry.api" 24 | """ 25 | 26 | pep_621_toml = """\ 27 | [project] 28 | name = "hello world" 29 | version = "0.1.0" 30 | description = "describing the project" 31 | authors = {authors} 32 | license = "" 33 | requires-python = "^3.8" 34 | 35 | [build-system] 36 | requires = ["hatchling"] 37 | build-backend = "hatchling.build" 38 | """ 39 | 40 | path = Path.cwd() 41 | 42 | 43 | def test_get_metadata_section(): 44 | expected = {"name": "hello world"} 45 | 46 | assert repo.get.get_metadata_section({"project": expected}) == expected 47 | assert repo.get.get_metadata_section({"tool": {"poetry": expected}}) == expected 48 | 49 | 50 | def test_get_authors_for_poetry_toml(monkeypatch): 51 | expected = '["Unit Test"]' 52 | data = poetry_toml.format(authors=expected) 53 | 54 | monkeypatch.setattr(repo.get, "get_pyproject_data", lambda _: tomlkit.loads(data)) 55 | 56 | assert repo.get.get_authors(path) == expected 57 | 58 | 59 | def test_get_authors_for_pep_621_compliant_toml(monkeypatch): 60 | authors = '[{name = "Unit Test", email = "the-email"}]' 61 | data = pep_621_toml.format(authors=authors) 62 | 63 | monkeypatch.setattr(repo.get, "get_pyproject_data", lambda _: tomlkit.loads(data)) 64 | 65 | assert repo.get.get_authors(path) == authors 66 | 67 | 68 | def test_get_python_version_for_poetry_toml(monkeypatch): 69 | data = pep_621_toml.format(authors=[]) 70 | 71 | monkeypatch.setattr(repo.get, "get_pyproject_data", lambda _: tomlkit.loads(data)) 72 | 73 | assert repo.get.get_python_version(path) == "^3.8" 74 | 75 | 76 | def test_get_python_version_for_pep_621_compliant_toml(monkeypatch): 77 | data = pep_621_toml.format(authors=[]) 78 | 79 | monkeypatch.setattr(repo.get, "get_pyproject_data", lambda _: tomlkit.loads(data)) 80 | 81 | assert repo.get.get_python_version(path) == "^3.8" 82 | -------------------------------------------------------------------------------- /components/polylith/check/collect.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Set 3 | 4 | from polylith import check, imports, workspace 5 | 6 | 7 | def extract_bricks(paths: Set[Path], ns: str) -> dict: 8 | all_imports = imports.fetch_all_imports(paths) 9 | 10 | return check.grouping.extract_brick_imports(all_imports, ns) 11 | 12 | 13 | def with_unknown_components(root: Path, ns: str, brick_imports: dict) -> dict: 14 | keys = set(brick_imports.keys()) 15 | values = set().union(*brick_imports.values()) 16 | 17 | unknowns = values.difference(keys) 18 | 19 | if not unknowns: 20 | return brick_imports 21 | 22 | paths = workspace.paths.collect_components_paths(root, ns, unknowns) 23 | 24 | extracted = extract_bricks(paths, ns) 25 | 26 | if not extracted: 27 | return brick_imports 28 | 29 | collected = {**brick_imports, **extracted} 30 | 31 | return with_unknown_components(root, ns, collected) 32 | 33 | 34 | def diff(known_bricks: Set[str], bases: Set[str], components: Set[str]) -> Set[str]: 35 | bricks = set().union(bases, components) 36 | 37 | return known_bricks.difference(bricks) 38 | 39 | 40 | def flatten_imported_bricks(imports: dict) -> Set[str]: 41 | return set().union(*imports.values()) 42 | 43 | 44 | def to_flattened_imports(brick_imports: dict) -> Set[str]: 45 | flattened_bases = flatten_imported_bricks(brick_imports["bases"]) 46 | flattened_components = flatten_imported_bricks(brick_imports["components"]) 47 | 48 | return set().union(flattened_bases, flattened_components) 49 | 50 | 51 | def imports_diff( 52 | brick_imports: dict, bases: Set[str], components: Set[str] 53 | ) -> Set[str]: 54 | flattened_imports = to_flattened_imports(brick_imports) 55 | 56 | return diff(flattened_imports, bases, components) 57 | 58 | 59 | def is_used(brick: str, imported_bricks: dict) -> bool: 60 | return any(k for k, v in imported_bricks.items() if k != brick and brick in v) 61 | 62 | 63 | def find_unused_bricks( 64 | brick_imports: dict, bases: Set[str], components: Set[str] 65 | ) -> Set[str]: 66 | all_brick_imports = {**brick_imports["bases"], **brick_imports["components"]} 67 | 68 | bricks = to_flattened_imports(brick_imports).difference(bases) 69 | used_bricks = {brick for brick in bricks if is_used(brick, all_brick_imports)} 70 | 71 | return components.difference(used_bricks) 72 | -------------------------------------------------------------------------------- /test/components/polylith/alias/test_alias.py: -------------------------------------------------------------------------------- 1 | from polylith import alias 2 | 3 | 4 | def test_parse_one_key_one_value_alias(): 5 | res = alias.parse(["opencv-python=cv2"]) 6 | 7 | assert res["opencv-python"] == ["cv2"] 8 | assert len(res.keys()) == 1 9 | 10 | 11 | def test_parse_one_key_many_values_alias(): 12 | res = alias.parse(["matplotlib=matplotlib, mpl_toolkits"]) 13 | 14 | assert res["matplotlib"] == ["matplotlib", "mpl_toolkits"] 15 | assert len(res.keys()) == 1 16 | 17 | 18 | def test_parse_many_keys_many_values_alias(): 19 | res = alias.parse(["matplotlib=matplotlib, mpl_toolkits", "opencv-python=cv2"]) 20 | 21 | assert res["matplotlib"] == ["matplotlib", "mpl_toolkits"] 22 | assert res["opencv-python"] == ["cv2"] 23 | 24 | assert len(res.keys()) == 2 25 | 26 | 27 | def test_pick_alias_by_key(): 28 | aliases = {"opencv-python": ["cv2"]} 29 | 30 | keys = {"one", "two", "opencv-python", "three"} 31 | 32 | res = alias.pick(aliases, keys) 33 | 34 | assert res == {"cv2"} 35 | 36 | 37 | def test_pick_aliases_by_keys(): 38 | aliases = {"opencv-python": ["cv2"], "matplotlib": ["mpl_toolkits", "matplotlib"]} 39 | 40 | keys = {"one", "two", "opencv-python", "matplotlib", "three"} 41 | 42 | res = alias.pick(aliases, keys) 43 | 44 | assert res == {"cv2", "mpl_toolkits", "matplotlib"} 45 | 46 | 47 | def test_pick_aliases_by_case_insensitive_keys(): 48 | aliases = { 49 | "opencv-python": ["cv2"], 50 | "PyJWT": ["jwt"], 51 | "Jinja2": ["jinja2"], 52 | "PyYAML": ["_yaml", "yaml"], 53 | } 54 | 55 | keys = {"one", "two", "jinja2", "pyyaml", "opencv-python", "pyjwt"} 56 | 57 | res = alias.pick(aliases, keys) 58 | 59 | assert res == {"cv2", "jinja2", "jwt", "_yaml", "yaml"} 60 | 61 | 62 | def test_pick_aliases_by_keys_using_normalized_names(): 63 | aliases = { 64 | "name_with_underscore": ["with_underscore"], 65 | "name-with-hyphen": ["with_hyphen"], 66 | "name.something": ["with_dot"], 67 | } 68 | 69 | keys = {"name-with-underscore", "name-with-hyphen", "name-something"} 70 | 71 | res = alias.pick(aliases, keys) 72 | 73 | assert res == {"with_underscore", "with_hyphen", "with_dot"} 74 | 75 | 76 | def test_pick_empty_alias_by_keys(): 77 | aliases = {} 78 | 79 | keys = {"one", "two", "opencv-python", "matplotlib", "three"} 80 | 81 | res = alias.pick(aliases, keys) 82 | 83 | assert res == set() 84 | -------------------------------------------------------------------------------- /components/polylith/parsing/rewrite.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from pathlib import Path 3 | from typing import List, Union 4 | 5 | 6 | def create_namespace_path(top_ns: str, current: str) -> str: 7 | top_ns_module_path = top_ns.replace("/", ".") 8 | return f"{top_ns_module_path}.{current}" 9 | 10 | 11 | def mutate_import(node: ast.Import, ns: str, top_ns: str) -> bool: 12 | did_mutate = False 13 | 14 | for alias in node.names: 15 | if alias.name == ns: 16 | if alias.asname is None: 17 | alias.asname = alias.name 18 | 19 | alias.name = create_namespace_path(top_ns, alias.name) 20 | did_mutate = True 21 | 22 | return did_mutate 23 | 24 | 25 | def mutate_import_from(node: ast.ImportFrom, ns: str, top_ns: str) -> bool: 26 | did_mutate = False 27 | 28 | if not node.module or node.level != 0: 29 | return did_mutate 30 | 31 | if node.module == ns or node.module.startswith(f"{ns}."): 32 | node.module = create_namespace_path(top_ns, node.module) 33 | did_mutate = True 34 | 35 | return did_mutate 36 | 37 | 38 | def mutate_imports(node: ast.AST, ns: str, top_ns: str) -> bool: 39 | if isinstance(node, ast.Import): 40 | return mutate_import(node, ns, top_ns) 41 | 42 | if isinstance(node, ast.ImportFrom): 43 | return mutate_import_from(node, ns, top_ns) 44 | 45 | return False 46 | 47 | 48 | def rewrite(source: Path, ns: str, top_ns: str) -> bool: 49 | file_path = source.as_posix() 50 | 51 | with open(file_path, "r", encoding="utf-8") as f: 52 | tree = ast.parse(f.read(), source.name) 53 | 54 | res = {mutate_imports(node, ns, top_ns) for node in ast.walk(tree)} 55 | 56 | if True in res: 57 | rewritten_source_code = ast.unparse(tree) # type: ignore[attr-defined] 58 | 59 | with open(file_path, "w", encoding="utf-8", newline="") as f: 60 | f.write(rewritten_source_code) 61 | 62 | return True 63 | 64 | return False 65 | 66 | 67 | def rewrite_module(module: Path, ns: str, top_ns: str) -> Union[str, None]: 68 | was_rewritten = rewrite(module, ns, top_ns) 69 | 70 | return f"{module.parent.name}/{module.name}" if was_rewritten else None 71 | 72 | 73 | def rewrite_modules(path: Path, ns: str, top_ns: str) -> List[str]: 74 | """Rewrite modules in bricks with new top namespace 75 | 76 | returns a list of bricks that was rewritten 77 | """ 78 | 79 | modules = path.glob("**/*.py") 80 | 81 | res = [rewrite_module(module, ns, top_ns) for module in modules] 82 | 83 | return [r for r in res if r] 84 | -------------------------------------------------------------------------------- /components/polylith/deps/core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Set 3 | 4 | from polylith import check, workspace 5 | 6 | 7 | def get_brick_imports( 8 | root: Path, ns: str, bases: Set[str], components: Set[str] 9 | ) -> dict: 10 | bases_paths = workspace.paths.collect_bases_paths(root, ns, bases) 11 | comp_paths = workspace.paths.collect_components_paths(root, ns, components) 12 | 13 | brick_imports_in_bases = check.collect.extract_bricks(bases_paths, ns) 14 | brick_imports_in_components = check.collect.extract_bricks(comp_paths, ns) 15 | 16 | return { 17 | "bases": check.collect.with_unknown_components( 18 | root, ns, brick_imports_in_bases 19 | ), 20 | "components": check.collect.with_unknown_components( 21 | root, ns, brick_imports_in_components 22 | ), 23 | } 24 | 25 | 26 | def without(key: str, bricks: Set[str]) -> Set[str]: 27 | return {b for b in bricks if b != key} 28 | 29 | 30 | def sorted_usings(usings: Set[str], bases: Set[str], components: Set[str]) -> List[str]: 31 | usings_bases = sorted({b for b in usings if b in bases}) 32 | usings_components = sorted({c for c in usings if c in components}) 33 | 34 | return usings_components + usings_bases 35 | 36 | 37 | def sorted_used_by( 38 | brick: str, bases: Set[str], components: Set[str], import_data: dict 39 | ) -> List[str]: 40 | brick_used_by = without(brick, {k for k, v in import_data.items() if brick in v}) 41 | 42 | return sorted_usings(brick_used_by, bases, components) 43 | 44 | 45 | def sorted_uses( 46 | brick: str, bases: Set[str], components: Set[str], import_data: dict 47 | ) -> List[str]: 48 | brick_uses = without(brick, import_data.get(brick, set())) 49 | 50 | return sorted_usings(brick_uses, bases, components) 51 | 52 | 53 | def calculate_brick_deps(brick: str, bricks: dict, import_data: dict) -> dict: 54 | bases = bricks["bases"] 55 | components = bricks["components"] 56 | 57 | brick_used_by = sorted_used_by(brick, bases, components, import_data) 58 | brick_uses = sorted_uses(brick, bases, components, import_data) 59 | 60 | return {"used_by": brick_used_by, "uses": brick_uses} 61 | 62 | 63 | def find_intersection_for_usings(usings: dict) -> Set[str]: 64 | uses = set(usings["uses"]) 65 | used_by = set(usings["used_by"]) 66 | 67 | return uses.intersection(used_by) 68 | 69 | 70 | def find_bricks_with_circular_dependencies(bricks_deps: dict) -> dict: 71 | res = {k: find_intersection_for_usings(v) for k, v in bricks_deps.items()} 72 | 73 | return {k: v for k, v in sorted(res.items()) if v} 74 | -------------------------------------------------------------------------------- /projects/poetry_polylith_plugin/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "poetry-polylith-plugin" 3 | version = "1.46.0" 4 | description = "A Poetry plugin that adds tooling support for the Polylith Architecture" 5 | authors = ["David Vujic"] 6 | homepage = "https://davidvujic.github.io/python-polylith-docs/" 7 | repository = "https://github.com/davidvujic/python-polylith" 8 | license = "MIT" 9 | readme = "README.md" 10 | 11 | packages = [ 12 | { include = "polylith/poetry_plugin", from = "../../bases" }, 13 | { include = "polylith/alias", from = "../../components" }, 14 | { include = "polylith/bricks", from = "../../components" }, 15 | { include = "polylith/check", from = "../../components" }, 16 | { include = "polylith/commands", from = "../../components" }, 17 | { include = "polylith/configuration", from = "../../components" }, 18 | { include = "polylith/deps", from = "../../components" }, 19 | { include = "polylith/development", from = "../../components" }, 20 | { include = "polylith/diff", from = "../../components" }, 21 | { include = "polylith/dirs", from = "../../components" }, 22 | { include = "polylith/distributions", from = "../../components" }, 23 | { include = "polylith/files", from = "../../components" }, 24 | { include = "polylith/imports", from = "../../components" }, 25 | { include = "polylith/info", from = "../../components" }, 26 | { include = "polylith/interactive",from = "../../components" }, 27 | { include = "polylith/interface", from = "../../components" }, 28 | { include = "polylith/libs", from = "../../components" }, 29 | { include = "polylith/output", from = "../../components" }, 30 | { include = "polylith/poetry", from = "../../components" }, 31 | { include = "polylith/project", from = "../../components" }, 32 | { include = "polylith/readme", from = "../../components" }, 33 | { include = "polylith/repo", from = "../../components" }, 34 | { include = "polylith/reporting", from = "../../components" }, 35 | { include = "polylith/sync", from = "../../components" }, 36 | { include = "polylith/test", from = "../../components" }, 37 | { include = "polylith/toml", from = "../../components" }, 38 | { include = "polylith/workspace", from = "../../components" }, 39 | { include = "polylith/yaml", from = "../../components" }, 40 | ] 41 | 42 | [tool.poetry.plugins."poetry.application.plugin"] 43 | poetry-polylith-plugin = "polylith.poetry_plugin:PolylithPlugin" 44 | 45 | [tool.poetry.dependencies] 46 | python = "^3.8" 47 | poetry = "*" 48 | tomlkit = "0.*" 49 | rich = ">=13,<15" 50 | cleo = "^2.1.0" 51 | pyyaml = "*" 52 | 53 | [build-system] 54 | requires = ["poetry-core>=1.0.0"] 55 | build-backend = "poetry.core.masonry.api" 56 | -------------------------------------------------------------------------------- /projects/polylith_cli/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "polylith-cli" 3 | version = "1.40.0" 4 | description = "Python tooling support for the Polylith Architecture" 5 | authors = ['David Vujic'] 6 | homepage = "https://davidvujic.github.io/python-polylith-docs/" 7 | repository = "https://github.com/davidvujic/python-polylith" 8 | license = "MIT" 9 | readme = "README.md" 10 | 11 | packages = [ 12 | { include = "polylith/cli", from = "../../bases" }, 13 | { include = "polylith/alias", from = "../../components" }, 14 | { include = "polylith/bricks", from = "../../components" }, 15 | { include = "polylith/building", from = "../../components" }, 16 | { include = "polylith/check", from = "../../components" }, 17 | { include = "polylith/commands", from = "../../components" }, 18 | { include = "polylith/configuration", from = "../../components" }, 19 | { include = "polylith/deps", from = "../../components" }, 20 | { include = "polylith/development", from = "../../components" }, 21 | { include = "polylith/diff", from = "../../components" }, 22 | { include = "polylith/dirs", from = "../../components" }, 23 | { include = "polylith/distributions", from = "../../components" }, 24 | { include = "polylith/environment", from = "../../components" }, 25 | { include = "polylith/files", from = "../../components" }, 26 | { include = "polylith/imports", from = "../../components" }, 27 | { include = "polylith/info", from = "../../components" }, 28 | { include = "polylith/interactive",from = "../../components" }, 29 | { include = "polylith/interface", from = "../../components" }, 30 | { include = "polylith/libs", from = "../../components" }, 31 | { include = "polylith/output", from = "../../components" }, 32 | { include = "polylith/parsing", from = "../../components" }, 33 | { include = "polylith/project", from = "../../components" }, 34 | { include = "polylith/readme", from = "../../components" }, 35 | { include = "polylith/repo", from = "../../components" }, 36 | { include = "polylith/reporting", from = "../../components" }, 37 | { include = "polylith/sync", from = "../../components" }, 38 | { include = "polylith/test", from = "../../components" }, 39 | { include = "polylith/toml", from = "../../components" }, 40 | { include = "polylith/workspace", from = "../../components" }, 41 | { include = "polylith/yaml", from = "../../components" }, 42 | ] 43 | 44 | [tool.poetry.dependencies] 45 | python = "^3.8" 46 | tomlkit = "0.*" 47 | rich = ">=13,<15" 48 | typer = "0.*" 49 | pyyaml = "*" 50 | 51 | [tool.poetry.scripts] 52 | poly = "polylith_cli.polylith.cli.core:app" 53 | 54 | [build-system] 55 | requires = ["poetry-core>=1.0.0"] 56 | build-backend = "poetry.core.masonry.api" 57 | -------------------------------------------------------------------------------- /components/polylith/project/get.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from pathlib import Path 3 | from typing import List 4 | 5 | import tomlkit 6 | from polylith import configuration, repo, toml 7 | from polylith.project import templates 8 | 9 | 10 | def get_project_name(toml_data) -> str: 11 | if repo.is_pep_621_ready(toml_data): 12 | return toml_data["project"]["name"] 13 | 14 | return toml_data["tool"]["poetry"]["name"] 15 | 16 | 17 | def get_project_name_from_toml(data: dict) -> str: 18 | try: 19 | return get_project_name(data["toml"]) 20 | except KeyError as e: 21 | path = data["path"] 22 | 23 | raise KeyError(f"Error in {path}") from e 24 | 25 | 26 | @lru_cache 27 | def get_toml(path: Path) -> tomlkit.TOMLDocument: 28 | return toml.read_toml_document(path) 29 | 30 | 31 | def get_project_files(root: Path) -> dict: 32 | projects = sorted(root.glob(f"projects/*/{repo.default_toml}")) 33 | development = Path(root / repo.default_toml) 34 | 35 | proj = {"projects": projects} 36 | dev = {"development": [development]} 37 | 38 | return {**proj, **dev} 39 | 40 | 41 | def toml_data(path: Path, project_type: str) -> dict: 42 | return {"toml": get_toml(path), "path": path.parent, "type": project_type} 43 | 44 | 45 | def get_toml_files(root: Path) -> List[dict]: 46 | project_files = get_project_files(root) 47 | 48 | proj = [toml_data(p, "project") for p in project_files["projects"]] 49 | dev = [toml_data(d, "development") for d in project_files["development"]] 50 | 51 | return proj + dev 52 | 53 | 54 | def get_packages_for_projects(root: Path) -> List[dict]: 55 | toml_files = get_toml_files(root) 56 | namespace = configuration.get_namespace_from_config(root) 57 | 58 | return [ 59 | { 60 | "name": get_project_name_from_toml(d), 61 | "packages": toml.get_project_package_includes(namespace, d["toml"]), 62 | "path": d["path"], 63 | "type": d["type"], 64 | "deps": toml.get_project_dependencies(d["toml"]), 65 | "exclude": toml.collect_configured_exclude_patterns(d["toml"]), 66 | } 67 | for d in toml_files 68 | ] 69 | 70 | 71 | def _get_poetry_template(pyproject: dict) -> str: 72 | if repo.is_pep_621_ready(pyproject): 73 | return templates.poetry_pep621_pyproject_template 74 | 75 | return templates.poetry_pyproject_template 76 | 77 | 78 | def guess_project_template(pyproject: dict) -> str: 79 | if repo.is_poetry(pyproject): 80 | template = _get_poetry_template(pyproject) 81 | elif repo.is_hatch(pyproject): 82 | template = templates.hatch_pyproject_template 83 | elif repo.is_pdm(pyproject): 84 | template = templates.pdm_pyproject_template 85 | else: 86 | raise ValueError("Failed to guess the type of Project") 87 | 88 | return template 89 | 90 | 91 | def get_project_template(root: Path) -> str: 92 | root_pyproject = get_toml(root / repo.default_toml) 93 | 94 | return guess_project_template(root_pyproject) 95 | -------------------------------------------------------------------------------- /components/polylith/configuration/core.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Union 3 | 4 | from polylith import repo 5 | 6 | 7 | def get_namespace_from_config(path: Path) -> str: 8 | toml: dict = repo.load_workspace_config(path) 9 | 10 | return toml["tool"]["polylith"]["namespace"] 11 | 12 | 13 | def get_git_tag_pattern(toml: dict) -> str: 14 | """Fallback git tag pattern configuration""" 15 | return toml["tool"]["polylith"]["git_tag_pattern"] 16 | 17 | 18 | def get_tag_pattern_from_config(path: Path, key: Union[str, None]) -> Union[str, None]: 19 | toml: dict = repo.load_workspace_config(path) 20 | 21 | patterns = toml["tool"]["polylith"].get("tag", {}).get("patterns") 22 | 23 | if not key: 24 | return patterns["stable"] if patterns else get_git_tag_pattern(toml) 25 | 26 | return patterns.get(key) 27 | 28 | 29 | def get_tag_sort_options_from_config(path: Path) -> List[str]: 30 | toml: dict = repo.load_workspace_config(path) 31 | 32 | options = toml["tool"]["polylith"].get("tag", {}).get("sorting") 33 | # Default sorting option 34 | if options is None: 35 | return ["-committerdate"] 36 | return options 37 | 38 | 39 | def is_test_generation_enabled(path: Path) -> bool: 40 | toml: dict = repo.load_workspace_config(path) 41 | 42 | enabled = toml["tool"]["polylith"]["test"]["enabled"] 43 | return bool(enabled) 44 | 45 | 46 | def is_readme_generation_enabled(path: Path) -> bool: 47 | toml: dict = repo.load_workspace_config(path) 48 | 49 | enabled = toml["tool"]["polylith"].get("resources", {}).get("brick_docs_enabled") 50 | return bool(enabled) 51 | 52 | 53 | def get_theme_from_config(path: Path) -> str: 54 | toml: dict = repo.load_workspace_config(path) 55 | 56 | return toml["tool"]["polylith"]["structure"].get("theme") or "tdd" 57 | 58 | 59 | def get_brick_structure_from_config(path: Path) -> str: 60 | theme = get_theme_from_config(path) 61 | 62 | if theme == "loose": 63 | return "{brick}/{namespace}/{package}" 64 | 65 | return "{brick}/{package}/src/{namespace}/{package}" 66 | 67 | 68 | def get_tests_structure_from_config(path: Path) -> str: 69 | theme = get_theme_from_config(path) 70 | 71 | if theme == "loose": 72 | return "test/{brick}/{namespace}/{package}" 73 | 74 | return "{brick}/{package}/test/{namespace}/{package}" 75 | 76 | 77 | def get_resources_structure_from_config(path: Path) -> str: 78 | theme = get_theme_from_config(path) 79 | 80 | if theme == "loose": 81 | return "{brick}/{namespace}/{package}" 82 | 83 | return "{brick}/{package}" 84 | 85 | 86 | def get_output_dir(path: Path, command_name: str) -> str: 87 | toml: dict = repo.load_workspace_config(path) 88 | 89 | key = "output" 90 | 91 | tool = toml["tool"]["polylith"] 92 | commands = tool.get("commands", {}) 93 | command = commands.get(command_name, {}) 94 | 95 | output = command.get(key) or commands.get(key) 96 | fallback = f"{repo.development_dir}/poly" 97 | 98 | return output or fallback 99 | -------------------------------------------------------------------------------- /bases/polylith/cli/build.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import tomlkit 4 | from polylith import building, repo, toml 5 | from polylith.cli import options 6 | from typer import Exit, Typer 7 | from typing_extensions import Annotated 8 | 9 | app = Typer() 10 | 11 | 12 | def get_work_dir(root: Path, directory: str) -> Path: 13 | work_dir = building.get_work_dir({}) 14 | work_path = Path(directory) / work_dir if directory else work_dir 15 | 16 | return root / work_path 17 | 18 | 19 | def get_build_dir(root: Path, directory: str) -> Path: 20 | return root / Path(directory) if directory else root 21 | 22 | 23 | def get_project_data(build_dir: Path) -> tomlkit.TOMLDocument: 24 | fullpath = build_dir / repo.default_toml 25 | 26 | if not fullpath.exists(): 27 | raise Exit(code=1) 28 | 29 | return toml.read_toml_document(fullpath) 30 | 31 | 32 | @app.command("setup") 33 | def setup_command(directory: Annotated[str, options.directory] = ""): 34 | """Prepare a project before building a wheel or a source distribution (sdist). 35 | Run it before the build command of your Package & Dependency Management tool. 36 | 37 | """ 38 | root = Path.cwd() 39 | build_dir = get_build_dir(root, directory) 40 | print(f"Build directory: {build_dir}") 41 | 42 | data = get_project_data(build_dir) 43 | bricks = toml.get_project_packages_from_polylith_section(data) 44 | 45 | if not bricks: 46 | print("No bricks found.") 47 | return 48 | 49 | bricks_with_paths = {build_dir / k: v for k, v in bricks.items()} 50 | custom_top_ns = toml.get_custom_top_namespace_from_polylith_section(data) 51 | 52 | if not custom_top_ns: 53 | building.copy_bricks_as_is(bricks_with_paths, build_dir) 54 | else: 55 | work_dir = get_work_dir(root, directory) 56 | print(f"Using temporary working directory: {work_dir}") 57 | 58 | rewritten = building.copy_and_rewrite_bricks( 59 | bricks_with_paths, custom_top_ns, work_dir, build_dir 60 | ) 61 | 62 | for item in rewritten: 63 | print(f"Updated {item} with new top namespace for local imports.") 64 | 65 | 66 | @app.command("teardown") 67 | def teardown_command(directory: Annotated[str, options.directory] = ""): 68 | """Clean up temporary directories. Run it after the build command of your Package & Dependency Management tool.""" 69 | root = Path.cwd() 70 | 71 | work_dir = get_work_dir(root, directory) 72 | build_dir = get_build_dir(root, directory) 73 | 74 | data = get_project_data(build_dir) 75 | bricks = toml.get_project_packages_from_polylith_section(data) 76 | 77 | if not bricks: 78 | return 79 | 80 | destination_dir = building.calculate_destination_dir(data) 81 | 82 | if work_dir.exists(): 83 | print(f"Removing temporary working directory: {work_dir}") 84 | building.cleanup(work_dir) 85 | 86 | if destination_dir: 87 | destination_path = build_dir / destination_dir 88 | print(f"Removing bricks path used during build: {destination_path}") 89 | building.cleanup(destination_path) 90 | -------------------------------------------------------------------------------- /test/components/polylith/libs/test_report.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from polylith.libs import report 3 | 4 | third_party_libs = { 5 | "cleo", 6 | "mypy-extensions", 7 | "poetry", 8 | "tomlkit", 9 | "requests", 10 | "rich", 11 | } 12 | 13 | 14 | def test_calculate_diff_reports_no_diff(): 15 | brick_imports = { 16 | "bases": {"my_base": {"rich"}}, 17 | "components": { 18 | "one": {"rich"}, 19 | "two": {"rich", "cleo"}, 20 | "thre": {"tomlkit"}, 21 | }, 22 | } 23 | 24 | res = report.calculate_diff(brick_imports, third_party_libs) 25 | 26 | assert len(res) == 0 27 | 28 | 29 | def test_calculate_diff_should_report_missing_dependency(): 30 | expected_missing = "aws-lambda-powertools" 31 | 32 | brick_imports = { 33 | "bases": {"my_base": {"poetry"}}, 34 | "components": { 35 | "one": {"tomlkit"}, 36 | "two": {"tomlkit", expected_missing, "rich"}, 37 | "three": {"rich"}, 38 | }, 39 | } 40 | 41 | res = report.calculate_diff(brick_imports, third_party_libs) 42 | 43 | assert res == {expected_missing} 44 | 45 | 46 | @pytest.mark.parametrize( 47 | "imports, is_strict", 48 | [ 49 | ({"aws_lambda_powertools", "PIL", "pyyoutube"}, False), 50 | ({"typing_extensions"}, True), 51 | ], 52 | ) 53 | def test_calculate_diff_should_identify_close_match(imports: set, is_strict: bool): 54 | brick_imports = { 55 | "bases": {"thebase": {"typer"}}, 56 | "components": {"one": imports}, 57 | } 58 | 59 | libs = { 60 | "aws-lambda-powertools", 61 | "pillow", 62 | "python-youtube", 63 | "typer", 64 | "typing-extensions", 65 | } 66 | 67 | res = report.calculate_diff(brick_imports, libs, is_strict) 68 | 69 | assert len(res) == 0 70 | 71 | 72 | def test_libs_versions_diff(): 73 | dev_data = {"deps": {"items": {"rich": "13.*"}}} 74 | projects_data = [{"name": "one", "deps": {"items": {"rich": "13.*"}}}] 75 | 76 | assert report.libs_with_different_versions(dev_data, projects_data) == set() 77 | 78 | 79 | def test_libs_versions_diff_should_return_libs_with_different_versions(): 80 | dev_data = {"deps": {"items": {"rich": "13.*"}}} 81 | 82 | proj_one = {"name": "one", "deps": {"items": {"rich": "13.*"}}} 83 | proj_two = {"name": "two", "deps": {"items": {"rich": "11.*"}}} 84 | projects_data = [proj_one, proj_two] 85 | 86 | res = report.libs_with_different_versions(dev_data, projects_data) 87 | 88 | assert res == {"rich"} 89 | 90 | 91 | def test_libs_versions_diff_should_only_return_libs_with_different_versions(): 92 | dev_data = {"deps": {"items": {"rich": "13.*"}}} 93 | 94 | proj_one = {"name": "one", "deps": {"items": {"rich": "13.*"}}} 95 | proj_two = {"name": "two", "deps": {"items": {}}} 96 | 97 | projects_data = [proj_one, proj_two] 98 | 99 | res = report.libs_with_different_versions(dev_data, projects_data) 100 | 101 | assert res == set() 102 | -------------------------------------------------------------------------------- /projects/pdm_polylith_bricks/README.md: -------------------------------------------------------------------------------- 1 | # PDM Build Hook for Polylith 2 | 3 | A plugin for [PDM](https://pdm-project.org) and the Polylith Architecture. 4 | 5 | This build hook will look for Polylith `bricks` in `pyproject.toml` and __optionally__ re-write the imports made in the source code. 6 | 7 | ## Installation 8 | ``` toml 9 | [build-system] 10 | requires = ["pdm-backend", "pdm-polylith-bricks"] 11 | build-backend = "pdm.backend" 12 | ``` 13 | 14 | The main usage of the build hook is to identify the included Polylith bricks from the `pyproject.toml`, 15 | and hand them over to the PDM build process. Bricks are added to a project with relative paths, 16 | from the `bases` and `components` folders in a Polylith Workspace. 17 | The hook will copy the bricks into the temporary `.pdm-build` folder that is created by the `pdm build` command. 18 | This will make the built `wheel` and `sdist` include proper paths to the source code. 19 | 20 | Polylith Bricks are defined in the `tool.polylith.bricks` section of the `pyproject.toml`: 21 | 22 | ``` toml 23 | [tool.polylith.bricks] 24 | "../../bases/my_namespace/my_base" = "my_namespace/my_base" 25 | "../../components/my_namespace/my_component" = "my_namespace/my_component 26 | ``` 27 | 28 | ### Polylith documentation 29 | [the Python tools for the Polylith Architecture](https://davidvujic.github.io/python-polylith-docs) 30 | 31 | The build hook also add support for building Python libraries by re-writing source code with a custom top namespace. 32 | 33 | ## Why re-write code? 34 | Building libraries is supported in [the Python tools for the Polylith Architecture](https://davidvujic.github.io/python-polylith-docs), 35 | but you will need to consider that code will share the same top namespace with any other library built from the same monorepo. 36 | 37 | This can be a problem when more than one of your libraries are installed into the same virtual environment. 38 | Python libraries by default are installed in a "flat" folder structure, two libraries with the same top namespace will collide. 39 | 40 | _A Solution_: add a custom top namespace during packaging of the library with PDM and this build hook. 41 | 42 | ### How is this done? 43 | The code in this repo uses __AST__ (Abstract Syntax Tree) parsing to modify source code. 44 | The Python built-in `ast` module is used to parse and un-parse Python code. 45 | 46 | 47 | ## What's the output from this plugin? 48 | 49 | Without any custom namespace in the configuration: no changes in the code. Building and packaging as-is. 50 | 51 | ### With a Top Namespace configuration 52 | 53 | ``` toml 54 | [tool.polylith.build] 55 | top-namespace = "my_custom_namespace" 56 | ``` 57 | 58 | ```shell 59 | my_custom_namespace/ 60 | my_namespace/ 61 | /my_package 62 | __init__.py 63 | my_module.py 64 | ``` 65 | 66 | Before: 67 | ```python 68 | from my_namespace.my_package import my_function 69 | ``` 70 | 71 | After: 72 | ```python 73 | from my_custom_namespace.my_namespace.my_package import my_function 74 | ``` 75 | 76 | ## Polylith Documentation 77 | [the Python tools for the Polylith Architecture](https://davidvujic.github.io/python-polylith-docs) 78 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | # python-polylith Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team. All complaints will be reviewed and 59 | investigated and will result in a response that is deemed necessary and appropriate to the circumstances. 60 | The project team is obligated to maintain confidentiality with regard to the reporter of an incident. 61 | Further details of specific enforcement policies may be posted separately. 62 | 63 | Project maintainers who do not follow or enforce the Code of Conduct in good 64 | faith may face temporary or permanent repercussions as determined by other 65 | members of the project's leadership. 66 | -------------------------------------------------------------------------------- /test/components/polylith/project/test_project_get.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | import tomlkit 5 | from polylith.project import get 6 | 7 | poetry_toml = """\ 8 | [tool.poetry] 9 | name = "unit-test" 10 | 11 | [build-system] 12 | requires = ["poetry-core>=1.0.0"] 13 | build-backend = "poetry.core.masonry.api" 14 | """ 15 | 16 | poetry_pep_621_toml = """\ 17 | [tool.poetry] 18 | packages = [] 19 | 20 | [project] 21 | name = "unit-test" 22 | 23 | [build-system] 24 | requires = ["poetry-core>=1.0.0"] 25 | build-backend = "poetry.core.masonry.api" 26 | """ 27 | 28 | hatch_toml = """\ 29 | [project] 30 | name = "unit-test" 31 | 32 | [build-system] 33 | requires = ["hatchling"] 34 | build-backend = "hatchling.build" 35 | """ 36 | 37 | pdm_toml = """\ 38 | [project] 39 | name = "unit-test" 40 | 41 | [build-system] 42 | requires = ["pdm-backend"] 43 | build-backend = "pdm.backend" 44 | """ 45 | 46 | unknown_toml = """\ 47 | [project] 48 | name = "unit-test" 49 | 50 | [build-system] 51 | requires = ["some-backend"] 52 | build-backend = "some.backend" 53 | """ 54 | 55 | 56 | def test_get_project_name_from_poetry_project(): 57 | data = {"toml": tomlkit.loads(poetry_toml), "path": Path.cwd()} 58 | 59 | assert get.get_project_name_from_toml(data) == "unit-test" 60 | 61 | 62 | def test_get_project_name_from_pep_621_project(): 63 | data = {"toml": tomlkit.loads(hatch_toml), "path": Path.cwd()} 64 | 65 | assert get.get_project_name_from_toml(data) == "unit-test" 66 | 67 | 68 | def test_fetching_project_name_from_empty_project_raises_error(): 69 | path = Path.cwd() 70 | 71 | data = {"toml": tomlkit.loads(""), "path": path} 72 | 73 | with pytest.raises(KeyError) as e: 74 | get.get_project_name_from_toml(data) 75 | 76 | assert str(path) in str(e.value) 77 | 78 | 79 | def test_get_project_template_returns_poetry_template(): 80 | data = tomlkit.loads(poetry_toml) 81 | 82 | res = get.guess_project_template(data) 83 | 84 | assert 'requires = ["poetry-core>=1.0.0"]' in res 85 | assert '[tool.poetry.dependencies]\npython = "{python_version}"' in res 86 | assert '[project]\nname = "{name}"' not in res 87 | 88 | 89 | def test_get_project_template_returns_poetry_with_pep_621_support_template(): 90 | data = tomlkit.loads(poetry_pep_621_toml) 91 | 92 | res = get.guess_project_template(data) 93 | 94 | assert 'requires = ["poetry-core>=1.0.0"]' in res 95 | assert '[project]\nname = "{name}"' in res 96 | 97 | 98 | def test_get_project_template_returns_hatch_template(): 99 | data = tomlkit.loads(hatch_toml) 100 | 101 | res = get.guess_project_template(data) 102 | 103 | assert 'requires = ["hatchling", "hatch-polylith-bricks"]' in res 104 | 105 | 106 | def test_get_project_template_returns_pdm_template(): 107 | data = tomlkit.loads(pdm_toml) 108 | 109 | res = get.guess_project_template(data) 110 | 111 | assert 'requires = ["pdm-backend", "pdm-polylith-bricks"]' in res 112 | 113 | 114 | def test_get_project_template_for_unknown_raise_error(): 115 | data = tomlkit.loads(unknown_toml) 116 | 117 | with pytest.raises(ValueError): 118 | get.guess_project_template(data) 119 | -------------------------------------------------------------------------------- /components/polylith/hatch/hooks/bricks.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | from typing import Any, Dict, List, Set 4 | 5 | from hatchling.builders.hooks.plugin.interface import BuildHookInterface 6 | from polylith import parsing, repo, toml 7 | from polylith.hatch import core 8 | 9 | 10 | def get_build_section(data: dict) -> dict: 11 | return data.get("tool", {}).get("hatch", {}).get("build", {}) 12 | 13 | 14 | def is_in_path(key: str, paths: List[str]) -> bool: 15 | return any(key.startswith(p) for p in paths) 16 | 17 | 18 | def filter_dev_mode_bricks(data: dict, bricks: dict) -> dict: 19 | build_section = get_build_section(data) 20 | dev_mode_dirs = build_section.get("dev-mode-dirs") 21 | 22 | if not dev_mode_dirs: 23 | return bricks 24 | 25 | return {k: v for k, v in bricks.items() if not is_in_path(k, dev_mode_dirs)} 26 | 27 | 28 | def filtered_bricks(data: dict, version: str) -> dict: 29 | bricks = toml.get_project_packages_from_polylith_section(data) 30 | 31 | if version == "editable": 32 | return filter_dev_mode_bricks(data, bricks) 33 | 34 | return bricks 35 | 36 | 37 | def copy_bricks(bricks: dict, work_dir: Path, exclude_patterns: Set[str]) -> List[Path]: 38 | return [ 39 | parsing.copy_brick(source, brick, work_dir, exclude_patterns) 40 | for source, brick in bricks.items() 41 | ] 42 | 43 | 44 | def rewrite_modules(paths: List[Path], ns: str, top_ns: str) -> None: 45 | for path in paths: 46 | rewritten_bricks = parsing.rewrite_modules(path, ns, top_ns) 47 | 48 | for item in rewritten_bricks: 49 | print(f"Updated {item} with new top namespace for local imports.") 50 | 51 | 52 | class PolylithBricksHook(BuildHookInterface): 53 | PLUGIN_NAME = "polylith-bricks" 54 | 55 | def initialize(self, version: str, build_data: Dict[str, Any]) -> None: 56 | include_key = "force_include" 57 | root = self.root 58 | pyproject = Path(f"{root}/{repo.default_toml}") 59 | 60 | data = toml.read_toml_document(pyproject) 61 | bricks = filtered_bricks(data, version) 62 | found_bricks = {k: v for k, v in bricks.items() if Path(f"{root}/{k}").exists()} 63 | 64 | if not bricks or not found_bricks: 65 | return 66 | 67 | ns = parsing.parse_brick_namespace_from_path(bricks) 68 | top_ns = core.get_top_namespace(data, self.config) 69 | work_dir = core.get_work_dir(self.config) 70 | exclude_patterns = toml.collect_configured_exclude_patterns(data, self.target_name) 71 | 72 | if not top_ns and not exclude_patterns: 73 | build_data[include_key] = bricks 74 | return 75 | 76 | key = work_dir.as_posix() 77 | paths = copy_bricks(bricks, work_dir, exclude_patterns) 78 | 79 | if not top_ns: 80 | build_data[include_key] = {f"{key}/{ns}": ns} 81 | return 82 | 83 | rewrite_modules(paths, ns, top_ns) 84 | 85 | build_data[include_key][key] = top_ns 86 | 87 | def finalize(self, *args, **kwargs) -> None: 88 | work_dir = core.get_work_dir(self.config) 89 | 90 | if not work_dir.exists() or not work_dir.is_dir(): 91 | return 92 | 93 | shutil.rmtree(work_dir.as_posix()) 94 | -------------------------------------------------------------------------------- /test/components/polylith/diff/test_collect.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from polylith.diff import collect 5 | 6 | root = Path.cwd() 7 | ns = "my_namespace" 8 | 9 | subfolder = "python" 10 | workspace_root_in_subfolder = Path(subfolder) 11 | 12 | changed_files_loose = [ 13 | Path(f"components/{ns}/a/core.py"), 14 | Path(f"some/other/{ns}/file.py"), 15 | Path(f"bases/{ns}/b/core.py"), 16 | Path(f"components/{ns}/b/core.py"), 17 | Path(f"components/{ns}/c/nested/subfolder/core.py"), 18 | Path(f"test/components/{ns}/x/core.py"), 19 | Path("projects/z/pyproject.toml"), 20 | ] 21 | 22 | changed_files_tdd = [ 23 | Path(f"some/other/{ns}/file.py"), 24 | Path(f"bases/b/src/{ns}/b/core.py"), 25 | Path(f"components/a/src/{ns}/a/core.py"), 26 | Path(f"components/b/src/{ns}/b/core.py"), 27 | Path(f"components/{ns}/x/core.py"), 28 | Path(f"components/c/src/{ns}/c/nested/subfolder/core.py"), 29 | Path(f"components/x/test/{ns}/x/core.py"), 30 | Path("projects/z/pyproject.toml"), 31 | ] 32 | 33 | 34 | @pytest.fixture 35 | def setup(monkeypatch): 36 | def set_theme(theme: str): 37 | monkeypatch.setattr( 38 | collect.configuration, "get_theme_from_config", lambda *args: theme 39 | ) 40 | 41 | return set_theme 42 | 43 | 44 | def test_get_changed_components(setup): 45 | setup(theme="loose") 46 | 47 | res = collect.get_changed_components(root, changed_files_loose, ns) 48 | 49 | assert res == ["a", "b", "c"] 50 | 51 | 52 | def test_get_changed_bases(setup): 53 | setup(theme="loose") 54 | 55 | res = collect.get_changed_bases(root, changed_files_loose, ns) 56 | 57 | assert res == ["b"] 58 | 59 | 60 | def test_get_changed_components_with_tdd_theme(setup): 61 | setup(theme="tdd") 62 | 63 | res = collect.get_changed_components(root, changed_files_tdd, ns) 64 | 65 | assert res == ["a", "b", "c"] 66 | 67 | 68 | def test_get_changed_bases_with_tdd_theme(setup): 69 | setup(theme="tdd") 70 | 71 | res = collect.get_changed_bases(root, changed_files_tdd, ns) 72 | 73 | assert res == ["b"] 74 | 75 | 76 | def test_get_changed_components_with_workspace_in_sub_folder(setup): 77 | setup(theme="loose") 78 | 79 | changes = [Path(f"{subfolder}/{p.as_posix()}") for p in changed_files_loose] 80 | 81 | res = collect.get_changed_components(workspace_root_in_subfolder, changes, ns) 82 | 83 | assert res == ["a", "b", "c"] 84 | 85 | 86 | def test_get_changed_components_with_workspace_in_sub_folder_tdd_theme(setup): 87 | setup(theme="tdd") 88 | 89 | changes = [Path(f"{subfolder}/{p.as_posix()}") for p in changed_files_tdd] 90 | 91 | res = collect.get_changed_components(workspace_root_in_subfolder, changes, ns) 92 | 93 | assert res == ["a", "b", "c"] 94 | 95 | 96 | def test_get_changed_projects(): 97 | res = collect.get_changed_projects(root, changed_files_loose) 98 | 99 | assert res == ["z"] 100 | 101 | 102 | def test_get_changed_projects_in_subfolder(): 103 | changes = [Path(f"{subfolder}/{p.as_posix()}") for p in changed_files_loose] 104 | 105 | res = collect.get_changed_projects(workspace_root_in_subfolder, changes) 106 | 107 | assert res == ["z"] 108 | -------------------------------------------------------------------------------- /components/polylith/project/templates.py: -------------------------------------------------------------------------------- 1 | poetry_pyproject_template = """\ 2 | [tool.poetry] 3 | name = "{name}" 4 | version = "0.1.0" 5 | {description} 6 | {authors} 7 | license = "" 8 | 9 | packages = [] 10 | 11 | [tool.poetry.dependencies] 12 | python = "{python_version}" 13 | 14 | [build-system] 15 | requires = ["poetry-core>=1.0.0"] 16 | build-backend = "poetry.core.masonry.api" 17 | """ 18 | 19 | poetry_pep621_pyproject_template = """\ 20 | [tool.poetry] 21 | packages = [] 22 | 23 | [project] 24 | name = "{name}" 25 | version = "0.1.0" 26 | {description} 27 | {authors} 28 | 29 | requires-python = "{python_version}" 30 | 31 | dependencies = [] 32 | 33 | [build-system] 34 | requires = ["poetry-core>=1.0.0"] 35 | build-backend = "poetry.core.masonry.api" 36 | """ 37 | 38 | hatch_pyproject_template = """\ 39 | [build-system] 40 | requires = ["hatchling", "hatch-polylith-bricks"] 41 | build-backend = "hatchling.build" 42 | 43 | [project] 44 | name = "{name}" 45 | version = "0.1.0" 46 | {description} 47 | {authors} 48 | 49 | requires-python = "{python_version}" 50 | 51 | dependencies = [] 52 | 53 | [tool.hatch.build.targets.wheel] 54 | packages = ["{namespace}"] 55 | 56 | [tool.hatch.build.hooks.polylith-bricks] 57 | 58 | [tool.polylith.bricks] 59 | """ 60 | 61 | pdm_pyproject_template = """\ 62 | [build-system] 63 | requires = ["pdm-backend", "pdm-polylith-bricks"] 64 | build-backend = "pdm.backend" 65 | 66 | [project] 67 | name = "{name}" 68 | version = "0.1.0" 69 | {description} 70 | {authors} 71 | 72 | requires-python = "{python_version}" 73 | 74 | dependencies = [] 75 | 76 | [tool.polylith.bricks] 77 | """ 78 | poetry_pyproject_template = """\ 79 | [tool.poetry] 80 | name = "{name}" 81 | version = "0.1.0" 82 | {description} 83 | {authors} 84 | license = "" 85 | 86 | packages = [] 87 | 88 | [tool.poetry.dependencies] 89 | python = "{python_version}" 90 | 91 | [build-system] 92 | requires = ["poetry-core>=1.0.0"] 93 | build-backend = "poetry.core.masonry.api" 94 | """ 95 | 96 | poetry_pep621_pyproject_template = """\ 97 | [tool.poetry] 98 | packages = [] 99 | 100 | [project] 101 | name = "{name}" 102 | version = "0.1.0" 103 | {description} 104 | {authors} 105 | 106 | requires-python = "{python_version}" 107 | 108 | dependencies = [] 109 | 110 | [build-system] 111 | requires = ["poetry-core>=1.0.0"] 112 | build-backend = "poetry.core.masonry.api" 113 | """ 114 | 115 | hatch_pyproject_template = """\ 116 | [build-system] 117 | requires = ["hatchling", "hatch-polylith-bricks"] 118 | build-backend = "hatchling.build" 119 | 120 | [project] 121 | name = "{name}" 122 | version = "0.1.0" 123 | {description} 124 | {authors} 125 | 126 | requires-python = "{python_version}" 127 | 128 | dependencies = [] 129 | 130 | [tool.hatch.build.targets.wheel] 131 | packages = ["{namespace}"] 132 | 133 | [tool.hatch.build.hooks.polylith-bricks] 134 | 135 | [tool.polylith.bricks] 136 | """ 137 | 138 | pdm_pyproject_template = """\ 139 | [build-system] 140 | requires = ["pdm-backend", "pdm-polylith-bricks"] 141 | build-backend = "pdm.backend" 142 | 143 | [project] 144 | name = "{name}" 145 | version = "0.1.0" 146 | {description} 147 | {authors} 148 | 149 | requires-python = "{python_version}" 150 | 151 | dependencies = [] 152 | 153 | [tool.polylith.bricks] 154 | """ 155 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "python-polylith" 3 | version = "0.0.2" 4 | description = "A Python Polylith repo adding tooling support for the Polylith Architecture" 5 | authors = ["David Vujic"] 6 | homepage = "https://github.com/davidvujic/python-polylith" 7 | repository = "https://github.com/davidvujic/python-polylith" 8 | readme = "README.md" 9 | packages = [ 10 | { include = "polylith/poetry_plugin", from = "bases" }, 11 | { include = "polylith/cli", from = "bases" }, 12 | { include = "polylith/hatch_hooks", from = "bases" }, 13 | { include = "polylith/pdm_project_hooks", from = "bases" }, 14 | { include = "polylith/pdm_workspace_hooks", from = "bases" }, 15 | { include = "polylith/alias", from = "components" }, 16 | { include = "polylith/bricks", from = "components" }, 17 | { include = "polylith/building", from = "components" }, 18 | { include = "polylith/check", from = "components" }, 19 | { include = "polylith/configuration", from = "components" }, 20 | { include = "polylith/commands", from = "components" }, 21 | { include = "polylith/deps", from = "components" }, 22 | { include = "polylith/development", from = "components" }, 23 | { include = "polylith/diff", from = "components" }, 24 | { include = "polylith/dirs", from = "components" }, 25 | { include = "polylith/distributions", from = "components" }, 26 | { include = "polylith/environment", from = "components" }, 27 | { include = "polylith/files", from = "components" }, 28 | { include = "polylith/hatch", from = "components" }, 29 | { include = "polylith/imports", from = "components" }, 30 | { include = "polylith/info", from = "components" }, 31 | { include = "polylith/interactive",from = "components" }, 32 | { include = "polylith/interface", from = "components" }, 33 | { include = "polylith/libs", from = "components" }, 34 | { include = "polylith/output", from = "components" }, 35 | { include = "polylith/parsing", from = "components" }, 36 | { include = "polylith/pdm", from = "components" }, 37 | { include = "polylith/poetry", from = "components" }, 38 | { include = "polylith/project", from = "components" }, 39 | { include = "polylith/readme", from = "components" }, 40 | { include = "polylith/repo", from = "components" }, 41 | { include = "polylith/reporting", from = "components" }, 42 | { include = "polylith/sync", from = "components" }, 43 | { include = "polylith/test", from = "components" }, 44 | { include = "polylith/toml", from = "components" }, 45 | { include = "polylith/yaml", from = "components" }, 46 | { include = "polylith/workspace", from = "components" }, 47 | { include = "development" }, 48 | ] 49 | 50 | [tool.poetry.dependencies] 51 | python = "^3.8.1" 52 | poetry = "*" 53 | tomlkit = "0.*" 54 | rich = ">=13,<15" 55 | cleo = "^2.1.0" 56 | hatchling = "^1.21.0" 57 | typer = "0.*" 58 | pyyaml = "*" 59 | 60 | [tool.poetry.group.dev.dependencies] 61 | black = "^24.3.0" 62 | isort = "^5.10.1" 63 | mypy = "^1.14.0" 64 | flake8 = "^7.0.0" 65 | pytest = "^8.3.5" 66 | types-pyyaml = "^6.0.12.20241230" 67 | 68 | [tool.poetry.scripts] 69 | poly = "polylith.cli.core:app" 70 | 71 | [tool.isort] 72 | profile = "black" 73 | 74 | [build-system] 75 | requires = ["poetry-core>=1.0.0"] 76 | build-backend = "poetry.core.masonry.api" 77 | 78 | [tool.pytest.ini_options] 79 | addopts = "-sv" 80 | -------------------------------------------------------------------------------- /test/components/polylith/project/test_templates.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tomlkit 3 | from polylith.project.templates import ( 4 | hatch_pyproject_template, 5 | pdm_pyproject_template, 6 | poetry_pyproject_template, 7 | ) 8 | 9 | template_data = { 10 | "name": "a project", 11 | "description": "", 12 | "authors": "", 13 | "python_version": "3.12", 14 | "namespace": "unit_test", 15 | } 16 | 17 | 18 | def with_optionals(description_field: str, authors_field: str) -> dict: 19 | return { 20 | **template_data, 21 | **{"description": description_field, "authors": authors_field}, 22 | } 23 | 24 | 25 | def with_poetry_optionals(description: str, author: str) -> dict: 26 | description_field = f'description = "{description}"' 27 | authors_field = f'authors = ["{author}"]' 28 | 29 | return with_optionals(description_field, authors_field) 30 | 31 | 32 | def with_pep621_optionals(description: str, author: str) -> dict: 33 | description_field = f'description = "{description}"' 34 | authors_field = str.replace('authors = [{ name = "_AUTHOR_"}]', "_AUTHOR_", author) 35 | 36 | return with_optionals(description_field, authors_field) 37 | 38 | 39 | def to_toml(template: str, data: dict): 40 | return tomlkit.loads(template.format(**data)) 41 | 42 | 43 | def test_poetry_template(): 44 | data = to_toml(poetry_pyproject_template, template_data) 45 | 46 | assert data["tool"]["poetry"] is not None 47 | assert data["tool"]["poetry"].get("authors") is None 48 | assert data["tool"]["poetry"].get("description") is None 49 | 50 | 51 | def test_poetry_template_with_optionals(): 52 | expected_description = "Hello world" 53 | expected_author = "Unit test" 54 | 55 | data = to_toml( 56 | poetry_pyproject_template, 57 | with_poetry_optionals(expected_description, expected_author), 58 | ) 59 | 60 | assert data["tool"]["poetry"]["description"] == expected_description 61 | assert data["tool"]["poetry"]["authors"] == [expected_author] 62 | 63 | 64 | def test_hatch_template(): 65 | data = to_toml(hatch_pyproject_template, template_data) 66 | 67 | assert "hatch-polylith-bricks" in data["build-system"]["requires"] 68 | assert data["tool"]["hatch"]["build"]["hooks"]["polylith-bricks"] == {} 69 | assert data["tool"]["polylith"]["bricks"] == {} 70 | 71 | assert data["project"].get("description") is None 72 | assert data["project"].get("authors") is None 73 | 74 | 75 | def test_pdm_template(): 76 | data = to_toml(pdm_pyproject_template, template_data) 77 | 78 | assert "pdm-polylith-bricks" in data["build-system"]["requires"] 79 | assert data["tool"]["polylith"]["bricks"] == {} 80 | 81 | assert data["project"].get("description") is None 82 | assert data["project"].get("authors") is None 83 | 84 | 85 | @pytest.mark.parametrize("name", ["hatch", "pdm"]) 86 | def test_pep621_template_with_optionals(name): 87 | expected_description = "Hello world" 88 | expected_author = "Unit Test" 89 | 90 | template = {"hatch": hatch_pyproject_template, "pdm": pdm_pyproject_template} 91 | 92 | data = to_toml( 93 | template[name], 94 | with_pep621_optionals(expected_description, expected_author), 95 | ) 96 | 97 | assert data["project"]["description"] == expected_description 98 | assert data["project"]["authors"] == [{"name": expected_author}] 99 | -------------------------------------------------------------------------------- /components/polylith/diff/report.py: -------------------------------------------------------------------------------- 1 | from typing import List, Set 2 | 3 | from polylith import info 4 | from polylith.reporting import theme 5 | from rich.console import Console 6 | from rich.padding import Padding 7 | 8 | 9 | def print_diff_details( 10 | projects_data: List[dict], bases: List[str], components: List[str] 11 | ) -> None: 12 | if not bases and not components: 13 | return 14 | 15 | console = Console(theme=theme.poly_theme) 16 | 17 | options = {"command": "diff"} 18 | table = info.report.build_bricks_in_projects_table( 19 | projects_data, bases, components, options 20 | ) 21 | 22 | console.print(table, overflow="ellipsis") 23 | 24 | 25 | def print_detected_changes(changes: List[str], markup: str, short: bool) -> None: 26 | if not changes: 27 | return 28 | 29 | console = Console(theme=theme.poly_theme) 30 | 31 | if short: 32 | console.out(",".join(changes)) 33 | return 34 | 35 | for brick in changes: 36 | console.print(f"[data]:gear: Changes found in [/][{markup}]{brick}[/]") 37 | 38 | 39 | def print_detected_dependent(bases: List[str], components: List[str]) -> None: 40 | console = Console(theme=theme.poly_theme) 41 | 42 | printable_bases = [f"[base]{b}[/]" for b in bases] 43 | printable_components = [f"[comp]{c}[/]" for c in components] 44 | 45 | printable_bricks = printable_components + printable_bases 46 | joined = ", ".join(printable_bricks) or "-" 47 | 48 | console.print(f"[data]:gear: Used by: [/]{joined}") 49 | 50 | 51 | def print_detected_changes_in_bricks( 52 | changed_bases: List[str], 53 | changed_components: List[str], 54 | dependent_bricks: dict, 55 | options: dict, 56 | ) -> None: 57 | short = options.get("short", False) 58 | with_deps = options.get("deps", False) 59 | 60 | dependent_bases = sorted(dependent_bricks.get("bases", set())) 61 | dependent_components = sorted(dependent_bricks.get("components", set())) 62 | 63 | bricks = changed_components + changed_bases + dependent_components + dependent_bases 64 | 65 | if short: 66 | print_detected_changes(bricks, "data", short) 67 | return 68 | 69 | print_detected_changes(changed_components, "comp", short) 70 | print_detected_changes(changed_bases, "base", short) 71 | 72 | if with_deps: 73 | print_detected_dependent(dependent_bases, dependent_components) 74 | 75 | 76 | def print_detected_changes_in_projects(projects: List[str], short: bool) -> None: 77 | print_detected_changes(projects, "proj", short) 78 | 79 | 80 | def print_projects_affected_by_changes(projects: Set[str], short: bool) -> None: 81 | sorted_projects = sorted(list(projects)) 82 | 83 | print_detected_changes(sorted_projects, "proj", short) 84 | 85 | 86 | def print_diff_summary(tag: str, bases: List[str], components: List[str]) -> None: 87 | console = Console(theme=theme.poly_theme) 88 | 89 | console.print(Padding(f"[data]Diff: based on {tag}[/]", (1, 0, 1, 0))) 90 | 91 | if not bases and not components: 92 | console.print("[data]No brick changes found.[/]") 93 | return 94 | 95 | if components: 96 | console.print(f"[comp]Changed components[/]: [data]{len(components)}[/]") 97 | 98 | if bases: 99 | console.print(f"[base]Changed bases[/]: [data]{len(bases)}[/]") 100 | -------------------------------------------------------------------------------- /projects/hatch_polylith_bricks/README.md: -------------------------------------------------------------------------------- 1 | # Hatch Build Hook for Polylith 2 | 3 | A plugin for [Hatch](https://hatch.pypa.io/) and the Polylith Architecture. 4 | 5 | This build hook will look for Polylith `bricks` in `pyproject.toml` and __optionally__ re-write the imports made in the source code. 6 | 7 | ## Installation 8 | ``` toml 9 | [build-system] 10 | requires = ["hatchling", "hatch-polylith-bricks"] 11 | build-backend = "hatchling.build" 12 | 13 | [tool.hatch.build.hooks.polylith-bricks] 14 | # NOTE: this section is needed to enable the hook in the build process, even if empty 15 | ``` 16 | 17 | This Build Hook has two main usages: 18 | * identify the included Polylith bricks from the `pyproject.toml`, and hand them over to the Hatch build process. 19 | * add support for building Python libraries by re-writing source code with a custom top namespace. 20 | 21 | Bricks are added to a project with relative paths, from the `bases` and `components` folders in a Polylith Workspace. 22 | The hook will add the bricks to the Hatch in-memory build config (`force-include`) provided by the Hatch build process. 23 | This will make the built `wheel` and `sdist` include proper paths to the source code. 24 | 25 | Polylith Bricks are defined in the `tool.polylith.bricks` section of the `pyproject.toml`: 26 | 27 | ``` toml 28 | [tool.polylith.bricks] 29 | "../../bases/my_namespace/my_base" = "my_namespace/my_base" 30 | "../../components/my_namespace/my_component" = "my_namespace/my_component 31 | ``` 32 | 33 | ### Polylith documentation 34 | [the Python tools for the Polylith Architecture](https://davidvujic.github.io/python-polylith-docs) 35 | 36 | 37 | ## Why re-write code? 38 | Building libraries is supported in [the Python tools for the Polylith Architecture](https://davidvujic.github.io/python-polylith-docs), 39 | but you will need to consider that code will share the same top namespace with any other library built from the same monorepo. 40 | 41 | This can be a problem when more than one of your libraries are installed into the same virtual environment. 42 | Python libraries by default are installed in a "flat" folder structure, two libraries with the same top namespace will collide. 43 | 44 | _A Solution_: add a custom top namespace during packaging of the library with Hatch and this build hook plugin. 45 | 46 | ## How is this done? 47 | The code in this repo uses __AST__ (Abstract Syntax Tree) parsing to modify source code. 48 | The Python built-in `ast` module is used to parse and un-parse Python code. 49 | 50 | 51 | ### What's the output from this plugin? 52 | 53 | Without any custom namespace in the configuration: no changes in the code. Building and packaging as-is. 54 | 55 | #### With a Top Namespace configuration 56 | 57 | ``` toml 58 | [tool.polylith.build] 59 | top-namespace = "my_custom_namespace" 60 | ``` 61 | 62 | ```shell 63 | my_custom_namespace/ 64 | my_namespace/ 65 | /my_package 66 | __init__.py 67 | my_module.py 68 | ``` 69 | 70 | Before: 71 | ```python 72 | from my_namespace.my_package import my_function 73 | ``` 74 | 75 | After: 76 | ```python 77 | from my_custom_namespace.my_namespace.my_package import my_function 78 | ``` 79 | 80 | ## Usage 81 | | Key | Default | Description | 82 | | --- | ------- | ----------- | 83 | | work-dir | .polylith_tmp | The temporary working directory for copying and re-writing source code. | 84 | 85 | 86 | ## Polylith documentation 87 | [the Python tools for the Polylith Architecture](https://davidvujic.github.io/python-polylith-docs) 88 | -------------------------------------------------------------------------------- /components/polylith/info/report.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from polylith import output 4 | from polylith.reporting import theme 5 | from rich import box 6 | from rich.console import Console 7 | from rich.padding import Padding 8 | from rich.table import Table 9 | 10 | 11 | def brick_status(brick, bricks, command: str) -> str: 12 | emoji = theme.check_emoji if command == "info" else ":gear:" 13 | 14 | status = emoji if brick in bricks else "-" 15 | 16 | return f"[data]{status}[/]" 17 | 18 | 19 | def is_project(project: dict) -> bool: 20 | return project["type"] == "project" 21 | 22 | 23 | def printable_name(project: dict, short: bool) -> str: 24 | if is_project(project): 25 | template = "[proj]{name}[/]" 26 | name = project["name"] 27 | else: 28 | template = "[data]{name}[/]" 29 | name = "development" 30 | 31 | if short: 32 | return template.format(name="\n".join(name)) 33 | 34 | return template.format(name=name) 35 | 36 | 37 | def build_bricks_in_projects_table( 38 | projects_data: List[dict], 39 | bases: List[str], 40 | components: List[str], 41 | options: dict, 42 | ) -> Table: 43 | short = options.get("short", False) 44 | command = options.get("command", "info") 45 | 46 | table = Table(box=box.SIMPLE_HEAD) 47 | table.add_column("[data]brick[/]") 48 | 49 | proj_cols = [printable_name(project, short) for project in projects_data] 50 | 51 | for col in proj_cols: 52 | table.add_column(col, justify="center") 53 | 54 | for brick in sorted(components): 55 | statuses = [ 56 | brick_status(brick, p.get("components"), command) for p in projects_data 57 | ] 58 | cols = [f"[comp]{brick}[/]"] + statuses 59 | 60 | table.add_row(*cols) 61 | 62 | for brick in sorted(bases): 63 | statuses = [brick_status(brick, p.get("bases"), command) for p in projects_data] 64 | cols = [f"[base]{brick}[/]"] + statuses 65 | 66 | table.add_row(*cols) 67 | 68 | return table 69 | 70 | 71 | def print_bricks_in_projects( 72 | projects_data: List[dict], 73 | bases: List[str], 74 | components: List[str], 75 | options: dict, 76 | ) -> None: 77 | table = build_bricks_in_projects_table(projects_data, bases, components, options) 78 | 79 | save = options.get("save", False) 80 | console = Console(theme=theme.poly_theme) 81 | 82 | console.print(table, overflow="ellipsis") 83 | 84 | if save: 85 | output.save(table, options, "info") 86 | 87 | 88 | def print_workspace_summary( 89 | projects_data: List[dict], 90 | bases: List[str], 91 | components: List[str], 92 | options: dict, 93 | ) -> None: 94 | save = options.get("save", False) 95 | console = Console(theme=theme.poly_theme, record=save) 96 | 97 | console.print(Padding("[data]Workspace summary[/]", (1, 0, 1, 0))) 98 | 99 | number_of_projects = len([p for p in projects_data if is_project(p)]) 100 | number_of_components = len(components) 101 | number_of_bases = len(bases) 102 | number_of_dev = len([p for p in projects_data if not is_project(p)]) 103 | 104 | console.print(f"[proj]projects[/]: [data]{number_of_projects}[/]") 105 | console.print(f"[comp]components[/]: [data]{number_of_components}[/]") 106 | console.print(f"[base]bases[/]: [data]{number_of_bases}[/]") 107 | console.print(f"[data]development[/]: [data]{number_of_dev}[/]") 108 | 109 | if save: 110 | output.save_recorded(console, options, "workspace_summary") 111 | -------------------------------------------------------------------------------- /components/polylith/distributions/core.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | import pathlib 3 | import re 4 | from functools import lru_cache, reduce 5 | from typing import Dict, List 6 | 7 | from polylith.distributions import caching 8 | 9 | SUB_DEP_SEPARATORS = r"[\s!=;><\^~]" 10 | 11 | 12 | def parse_sub_package_name(dependency: str) -> str: 13 | parts = re.split(SUB_DEP_SEPARATORS, dependency) 14 | 15 | return str(parts[0]) 16 | 17 | 18 | def dist_subpackages(dist) -> dict: 19 | name = dist.metadata["name"] 20 | dependencies = importlib.metadata.requires(name) or [] 21 | 22 | parsed_package_names = list({parse_sub_package_name(d) for d in dependencies}) 23 | 24 | return {name: parsed_package_names} if dependencies else {} 25 | 26 | 27 | def map_sub_packages(acc, dist) -> dict: 28 | return {**acc, **dist_subpackages(dist)} 29 | 30 | 31 | def parsed_namespaces_from_files(dist, name: str) -> List[str]: 32 | if not caching.exists(name): 33 | files = dist.files or [] 34 | python_files = [f for f in files if f.suffix == ".py"] 35 | caching.add(name, python_files) 36 | 37 | normalized_name = str.replace(name, "-", "_") 38 | to_ignore = { 39 | name, 40 | normalized_name, 41 | str.lower(name), 42 | str.lower(normalized_name), 43 | "..", 44 | } 45 | 46 | filtered: List[pathlib.PurePosixPath] = caching.get(name) 47 | top_folders = {f.parts[0] for f in filtered if len(f.parts) > 1} 48 | namespaces = {t for t in top_folders if t not in to_ignore} 49 | 50 | return list(namespaces) 51 | 52 | 53 | def parsed_top_level_namespace(namespaces: List[str]) -> List[str]: 54 | return [str.replace(ns, "/", ".") for ns in namespaces] 55 | 56 | 57 | def top_level_packages(dist, name: str) -> List[str]: 58 | top_level = dist.read_text("top_level.txt") 59 | 60 | namespaces = str.split(top_level or "") 61 | 62 | return parsed_top_level_namespace(namespaces) or parsed_namespaces_from_files( 63 | dist, name 64 | ) 65 | 66 | 67 | def mapped_packages(dist) -> dict: 68 | name = dist.metadata["name"] 69 | packages = top_level_packages(dist, name) 70 | 71 | return {name: packages} if packages else {} 72 | 73 | 74 | def map_packages(acc, dist) -> dict: 75 | return {**acc, **mapped_packages(dist)} 76 | 77 | 78 | def distributions_packages(dists) -> Dict[str, List[str]]: 79 | """Return a mapping of top-level packages to their distributions.""" 80 | return reduce(map_packages, dists, {}) 81 | 82 | 83 | def distributions_sub_packages(dists) -> Dict[str, List[str]]: 84 | """Return the dependencies of each distribution.""" 85 | return reduce(map_sub_packages, dists, {}) 86 | 87 | 88 | @lru_cache 89 | def get_distributions() -> list: 90 | return list(importlib.metadata.distributions()) 91 | 92 | 93 | @lru_cache 94 | def package_distributions_from_importlib() -> dict: 95 | # added in Python 3.10 96 | fn = getattr(importlib.metadata, "packages_distributions", None) 97 | 98 | return fn() if fn else {} 99 | 100 | 101 | def get_packages_distributions(project_dependencies: set) -> set: 102 | """Return the mapped top namespace from an import 103 | 104 | Example: 105 | A third-party library, such as opentelemetry-instrumentation-fastapi. 106 | The return value would be the mapped top namespace: opentelemetry 107 | 108 | Note: available for Python >= 3.10 109 | """ 110 | 111 | dists = package_distributions_from_importlib() 112 | 113 | common = {k for k, v in dists.items() if project_dependencies.intersection(set(v))} 114 | 115 | return common.difference(project_dependencies) 116 | -------------------------------------------------------------------------------- /projects/hatch_polylith_bricks/poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "hatchling" 5 | version = "1.25.0" 6 | description = "Modern, extensible Python build backend" 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "hatchling-1.25.0-py3-none-any.whl", hash = "sha256:b47948e45d4d973034584dd4cb39c14b6a70227cf287ab7ec0ad7983408a882c"}, 11 | {file = "hatchling-1.25.0.tar.gz", hash = "sha256:7064631a512610b52250a4d3ff1bd81551d6d1431c4eb7b72e734df6c74f4262"}, 12 | ] 13 | 14 | [package.dependencies] 15 | packaging = ">=23.2" 16 | pathspec = ">=0.10.1" 17 | pluggy = ">=1.0.0" 18 | tomli = {version = ">=1.2.2", markers = "python_version < \"3.11\""} 19 | trove-classifiers = "*" 20 | 21 | [[package]] 22 | name = "packaging" 23 | version = "24.1" 24 | description = "Core utilities for Python packages" 25 | optional = false 26 | python-versions = ">=3.8" 27 | files = [ 28 | {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, 29 | {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, 30 | ] 31 | 32 | [[package]] 33 | name = "pathspec" 34 | version = "0.12.1" 35 | description = "Utility library for gitignore style pattern matching of file paths." 36 | optional = false 37 | python-versions = ">=3.8" 38 | files = [ 39 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 40 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 41 | ] 42 | 43 | [[package]] 44 | name = "pluggy" 45 | version = "1.5.0" 46 | description = "plugin and hook calling mechanisms for python" 47 | optional = false 48 | python-versions = ">=3.8" 49 | files = [ 50 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 51 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 52 | ] 53 | 54 | [package.extras] 55 | dev = ["pre-commit", "tox"] 56 | testing = ["pytest", "pytest-benchmark"] 57 | 58 | [[package]] 59 | name = "tomli" 60 | version = "2.0.1" 61 | description = "A lil' TOML parser" 62 | optional = false 63 | python-versions = ">=3.7" 64 | files = [ 65 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 66 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 67 | ] 68 | 69 | [[package]] 70 | name = "tomlkit" 71 | version = "0.12.5" 72 | description = "Style preserving TOML library" 73 | optional = false 74 | python-versions = ">=3.7" 75 | files = [ 76 | {file = "tomlkit-0.12.5-py3-none-any.whl", hash = "sha256:af914f5a9c59ed9d0762c7b64d3b5d5df007448eb9cd2edc8a46b1eafead172f"}, 77 | {file = "tomlkit-0.12.5.tar.gz", hash = "sha256:eef34fba39834d4d6b73c9ba7f3e4d1c417a4e56f89a7e96e090dd0d24b8fb3c"}, 78 | ] 79 | 80 | [[package]] 81 | name = "trove-classifiers" 82 | version = "2024.7.2" 83 | description = "Canonical source for classifiers on PyPI (pypi.org)." 84 | optional = false 85 | python-versions = "*" 86 | files = [ 87 | {file = "trove_classifiers-2024.7.2-py3-none-any.whl", hash = "sha256:ccc57a33717644df4daca018e7ec3ef57a835c48e96a1e71fc07eb7edac67af6"}, 88 | {file = "trove_classifiers-2024.7.2.tar.gz", hash = "sha256:8328f2ac2ce3fd773cbb37c765a0ed7a83f89dc564c7d452f039b69249d0ac35"}, 89 | ] 90 | 91 | [metadata] 92 | lock-version = "2.0" 93 | python-versions = "^3.9" 94 | content-hash = "459af54d3cda5cbaa5534380d86443a14ecec4ed2d44f39875ad9680bdae29a4" 95 | -------------------------------------------------------------------------------- /components/polylith/repo/repo.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from pathlib import Path 3 | from typing import Union 4 | 5 | import tomlkit 6 | 7 | workspace_file = "workspace.toml" 8 | root_file = ".git" 9 | default_toml = "pyproject.toml" 10 | readme_file = "README.md" 11 | 12 | bases_dir = "bases" 13 | components_dir = "components" 14 | projects_dir = "projects" 15 | development_dir = "development" 16 | 17 | 18 | def load_content(fullpath: Path) -> tomlkit.TOMLDocument: 19 | content = fullpath.read_text() 20 | 21 | return tomlkit.loads(content) 22 | 23 | 24 | @lru_cache 25 | def load_root_project_config(path: Path) -> tomlkit.TOMLDocument: 26 | fullpath = path / default_toml 27 | 28 | if not fullpath.exists(): 29 | return tomlkit.TOMLDocument() 30 | 31 | return load_content(fullpath) 32 | 33 | 34 | def has_workspace_config(data: tomlkit.TOMLDocument) -> bool: 35 | ns = data.get("tool", {}).get("polylith", {}).get("namespace") 36 | 37 | return True if ns else False 38 | 39 | 40 | @lru_cache 41 | def load_workspace_config(path: Path) -> tomlkit.TOMLDocument: 42 | fullpath = path / workspace_file 43 | 44 | if fullpath.exists(): 45 | content = load_content(fullpath) 46 | 47 | if has_workspace_config(content): 48 | return content 49 | 50 | return load_root_project_config(path) 51 | 52 | 53 | def is_drive_root(cwd: Path) -> bool: 54 | return cwd == Path(cwd.root) or cwd == cwd.parent 55 | 56 | 57 | def is_repo_root(cwd: Path) -> bool: 58 | fullpath = cwd / root_file 59 | 60 | return fullpath.exists() 61 | 62 | 63 | def is_python_workspace_root(path: Path) -> bool: 64 | data = load_root_project_config(path) 65 | 66 | return has_workspace_config(data) 67 | 68 | 69 | def find_upwards(cwd: Path, name: str) -> Union[Path, None]: 70 | if is_drive_root(cwd): 71 | return None 72 | 73 | fullpath = cwd / name 74 | 75 | if fullpath.exists(): 76 | if name == workspace_file: 77 | return fullpath 78 | 79 | return fullpath if is_python_workspace_root(cwd) else None 80 | 81 | if is_repo_root(cwd): 82 | return None 83 | 84 | return find_upwards(cwd.parent, name) 85 | 86 | 87 | def find_upwards_dir(cwd: Path, name: str) -> Union[Path, None]: 88 | fullpath = find_upwards(cwd, name) 89 | 90 | return fullpath.parent if fullpath else None 91 | 92 | 93 | def find_workspace_root(cwd: Path) -> Union[Path, None]: 94 | workspace_root = find_upwards_dir(cwd, workspace_file) 95 | 96 | if workspace_root: 97 | return workspace_root 98 | 99 | repo_root = find_upwards_dir(cwd, root_file) 100 | 101 | return repo_root or find_upwards_dir(cwd, default_toml) 102 | 103 | 104 | def get_workspace_root(cwd: Path) -> Path: 105 | root = find_workspace_root(cwd) 106 | 107 | if not root: 108 | raise ValueError( 109 | "Didn't find the workspace root. Expected to find a workspace.toml or pyproject.toml with Workspace config." 110 | ) 111 | 112 | return root 113 | 114 | 115 | def has_build_requires(pyproject: dict, value: str) -> bool: 116 | backend = pyproject.get("build-system", {}).get("build-backend", "") 117 | 118 | return value in backend 119 | 120 | 121 | def is_poetry(pyproject: dict) -> bool: 122 | return has_build_requires(pyproject, "poetry") 123 | 124 | 125 | def is_hatch(pyproject: dict) -> bool: 126 | return has_build_requires(pyproject, "hatchling") 127 | 128 | 129 | def is_pdm(pyproject: dict) -> bool: 130 | return has_build_requires(pyproject, "pdm") 131 | 132 | 133 | def is_uv(pyproject: dict) -> bool: 134 | return has_build_requires(pyproject, "uv_build") 135 | 136 | 137 | def is_pep_621_ready(pyproject: dict) -> bool: 138 | return pyproject.get("project", {}).get("name") is not None 139 | -------------------------------------------------------------------------------- /components/polylith/interactive/project.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | from pathlib import Path 3 | from typing import List, Set, Union 4 | 5 | from polylith import configuration, info, repo, sync 6 | from polylith.reporting import theme 7 | from rich.console import Console 8 | from rich.padding import Padding 9 | from rich.prompt import Confirm, Prompt 10 | 11 | console = Console(theme=theme.poly_theme) 12 | 13 | 14 | def create_added_brick_message(bricks: Set[str], tag: str, project_name: str) -> str: 15 | number_of_bricks = len(bricks) 16 | plural = "s" if number_of_bricks > 1 else "" 17 | 18 | if tag == "base": 19 | grammar = f"base{plural}" 20 | else: 21 | grammar = f"component{plural}" 22 | 23 | return f"[data]Added {number_of_bricks} [{tag}]{grammar}[/] to the [proj]{project_name}[/] project.[/]" 24 | 25 | 26 | def confirmation(diff: dict, project_name: str) -> None: 27 | pad = (1, 0, 0, 0) 28 | 29 | if not diff: 30 | nothing_added_message = f"[data]No bricks added to [proj]{project_name}[/][/]." 31 | console.print(Padding(nothing_added_message, pad)) 32 | 33 | return 34 | 35 | bases = diff["bases"] 36 | components = diff["components"] 37 | 38 | bases_message = create_added_brick_message(bases, "base", project_name) 39 | console.print(Padding(bases_message, pad)) 40 | 41 | if len(components) == 0: 42 | return 43 | 44 | components_message = create_added_brick_message(components, "comp", project_name) 45 | console.print(components_message) 46 | 47 | 48 | def sort_bases_by_closest_match(bases: Set[str], name: str) -> List[str]: 49 | closest = difflib.get_close_matches(name, bases, cutoff=0.3) 50 | 51 | rest = sorted([b for b in bases if b not in closest]) 52 | 53 | return closest + rest 54 | 55 | 56 | def choose_base_for_project( 57 | root: Path, 58 | ns: str, 59 | project_name: str, 60 | possible_bases: Set[str], 61 | ) -> Union[str, None]: 62 | first, *_ = sort_bases_by_closest_match(possible_bases, project_name) 63 | 64 | if not Confirm.ask( 65 | prompt=f"[data]Do you want to add bricks to the [proj]{project_name}[/] project?[/]", 66 | console=console, 67 | ): 68 | return None 69 | 70 | question = "[data]What's the name of the Polylith [base]base[/] to add?[/]" 71 | 72 | base = Prompt.ask( 73 | prompt=question, 74 | console=console, 75 | default=first, 76 | show_default=True, 77 | case_sensitive=False, 78 | ) 79 | 80 | all_bases = info.get_bases(root, ns) 81 | 82 | return next((b for b in all_bases if str.lower(b) == str.lower(base)), None) 83 | 84 | 85 | def add_bricks_to_project( 86 | root: Path, 87 | ns: str, 88 | project_data: dict, 89 | possible_bases: Set[str], 90 | ) -> None: 91 | project_name = project_data["name"] 92 | found_base = choose_base_for_project(root, ns, project_name, possible_bases) 93 | 94 | if not found_base: 95 | confirmation({}, project_name) 96 | return 97 | 98 | diff = sync.calculate_needed_bricks(root, ns, project_data, found_base) 99 | 100 | sync.update_project(root, ns, diff) 101 | 102 | confirmation(diff, project_name) 103 | 104 | 105 | def run(project_name: str) -> None: 106 | root = repo.get_workspace_root(Path.cwd()) 107 | ns = configuration.get_namespace_from_config(root) 108 | 109 | possible_bases = info.find_unused_bases(root, ns) 110 | 111 | if not possible_bases: 112 | return 113 | 114 | projects_data = info.get_projects_data(root, ns) 115 | project_data = next((p for p in projects_data if p["name"] == project_name), None) 116 | 117 | if not project_data: 118 | return 119 | 120 | message = f"[data]Project [proj]{project_name}[/] created.[/]" 121 | console.print(Padding(message, (0, 0, 1, 0))) 122 | 123 | add_bricks_to_project(root, ns, project_data, possible_bases) 124 | --------------------------------------------------------------------------------