Skip to content

primitives

Private Methods

Private methods, if any (those starting with _), are documented for completeness but do not offer any stability guarantees. They may change or be removed at any time without notice.

Shared primitives for Polytropos.

_convert_readme_to_rst(root, readme_filename, src_dir, content_type, module_name)

Convert README to RST format using pandoc.

Parameters:

Name Type Description Default
root Path

Project root directory

required
readme_filename str

Name of the readme file

required
src_dir str | None

Source directory relative to root

required
content_type str

Content type of the readme file

required
module_name str

Module name for image path rewriting

required

Returns:

Type Description
str | None

RST converted content or None if readme is in src directory

Source code in src/polytropos/primitives.py
def _convert_readme_to_rst(
    root: Path,
    readme_filename: str,
    src_dir: str | None,
    content_type: str,
    module_name: str,
) -> str | None:
    """Convert README to RST format using pandoc.

    Args:
        root: Project root directory
        readme_filename: Name of the readme file
        src_dir: Source directory relative to root
        content_type: Content type of the readme file
        module_name: Module name for image path rewriting

    Returns:
        RST converted content or None if readme is in src directory
    """
    readme_in_src = src_dir and (root / src_dir / readme_filename).is_file()
    readme_path = root / readme_filename
    if readme_path.is_file() and not readme_in_src:
        readme_content = readme_path.read_text(encoding="utf-8")
        try:
            pandoc = local["pandoc"]
            if content_type == "text/markdown":
                pandoc_from = "gfm"
            elif content_type == "text/x-rst":
                pandoc_from = "rst"
            else:
                pandoc_from = "markdown"

            with local.env(MODULE_NAME=module_name, STRIP_IMG_PREFIX=src_dir or "."):
                result: str = pandoc(
                    f"--from={pandoc_from}",
                    "--to=rst",
                    "--standalone=true",
                    "--filter=pandoc-include",
                    f"--lua-filter={LUA_FILTER}",
                    "--metadata",
                    f"includes={readme_path.parent}",
                    str(readme_path),
                )
            return result.strip()
        except Exception as e:
            warnings.warn(
                f"Failed to convert README to RST; falling back to original content. "
                f"Error: {e}"
            )
            return readme_content
    return None

build_full_version(module_version, odoo_release)

Build the full version string for the module.

Combines the Odoo release (A.B) with the module version (X.Y.Z) to form A.B.X.Y.Z (5+ components, required by manifestoo-core).

Parameters:

Name Type Description Default
module_version str

Module version (e.g., "1.0.0", "1.2.3")

required
odoo_release str

Odoo release (e.g., "17.0")

required

Returns:

Type Description
str

Full version (e.g., "17.0.1.0.0", "18.0.1.2.3")

Raises:

Type Description
ValueError

If module version has fewer than 3 components

Source code in src/polytropos/primitives.py
def build_full_version(module_version: str, odoo_release: str) -> str:
    """Build the full version string for the module.

    Combines the Odoo release (A.B) with the module version (X.Y.Z)
    to form A.B.X.Y.Z (5+ components, required by manifestoo-core).

    Args:
        module_version: Module version (e.g., "1.0.0", "1.2.3")
        odoo_release: Odoo release (e.g., "17.0")

    Returns:
        Full version (e.g., "17.0.1.0.0", "18.0.1.2.3")

    Raises:
        ValueError: If module version has fewer than 3 components
    """
    release_version = Version(odoo_release)
    module_version_obj = Version(module_version)

    release_parts = [release_version.major, release_version.minor]

    mod_parts: list[int] = []
    if hasattr(module_version_obj, "release") and isinstance(
        module_version_obj.release, tuple
    ):
        mod_parts.extend(
            part for part in module_version_obj.release if part is not None
        )

    if not mod_parts:
        mod_parts = [module_version_obj.major, module_version_obj.minor]

    if len(mod_parts) < 3:
        raise ValueError(
            f"Module version must have at least 3 components (X.Y.Z), got {module_version}"
        )

    return ".".join(str(p) for p in release_parts + mod_parts)

check_dynamic_version(pyproject_data)

Check that version is declared as dynamic if present.

Without dynamic=['version'], tools like uv cannot properly resolve dependencies that depend on the Odoo release (e.g., odoo-addon-foo==18.0.*).

Parameters:

Name Type Description Default
pyproject_data dict[str, Any]

Parsed pyproject.toml

required
Source code in src/polytropos/primitives.py
def check_dynamic_version(pyproject_data: dict[str, Any]) -> None:
    """Check that version is declared as dynamic if present.

    Without dynamic=['version'], tools like uv cannot properly resolve
    dependencies that depend on the Odoo release (e.g., odoo-addon-foo==18.0.*).

    Args:
        pyproject_data: Parsed pyproject.toml
    """
    project = pyproject_data.get("project", {})
    version = project.get("version")
    dynamic = project.get("dynamic", [])

    if version is not None and "version" not in dynamic:
        warnings.warn(
            "version is defined in [project] but not listed in dynamic. "
            "Add 'dynamic = [\"version\"]' to [project] to enable proper "
            "dependency resolution. Without this, tools like uv may fail to "
            "resolve dependencies that depend on the Odoo release "
            "(e.g., odoo-addon-foo==18.0.*).",
            UserWarning,
            stacklevel=2,
        )

check_release_compatibility(odoo_release, pyproject_data)

Check if Odoo release is compatible with project.dependencies constraints.

Combines all odoo dependency entries (e.g. ["odoo>=18", "odoo<19"]) into a single specifier set before validation so that split constraints are handled identically to a single "odoo>=18,<19" entry.

Parameters:

Name Type Description Default
odoo_release str

Odoo release version (e.g., "17.0")

required
pyproject_data dict[str, Any]

Parsed pyproject.toml

required

Raises:

Type Description
ValueError

If release is incompatible with constraints

Source code in src/polytropos/primitives.py
def check_release_compatibility(
    odoo_release: str,
    pyproject_data: dict[str, Any],
) -> None:
    """Check if Odoo release is compatible with project.dependencies constraints.

    Combines all ``odoo`` dependency entries (e.g. ``["odoo>=18", "odoo<19"]``)
    into a single specifier set before validation so that split constraints are
    handled identically to a single ``"odoo>=18,<19"`` entry.

    Args:
        odoo_release: Odoo release version (e.g., "17.0")
        pyproject_data: Parsed pyproject.toml

    Raises:
        ValueError: If release is incompatible with constraints
    """
    from packaging.requirements import Requirement

    dependencies = pyproject_data.get("project", {}).get("dependencies", [])
    combined_spec = SpecifierSet()
    found = False
    for dep in dependencies:
        req = Requirement(dep)
        if req.name == "odoo":
            found = True
            combined_spec &= req.specifier
    if found and odoo_release not in combined_spec:
        raise ValueError(
            f"Cannot build for Odoo {odoo_release}: project requires odoo{combined_spec}"
        )

deep_merge(base, override)

Deep merge two dictionaries.

  • If value is a dict, recurse
  • If value is a list, extend base list with override list
  • Otherwise, override the value

Parameters:

Name Type Description Default
base dict[str, Any]

Base dictionary

required
override dict[str, Any]

Override dictionary

required

Returns:

Type Description
dict[str, Any]

Merged dictionary

Source code in src/polytropos/primitives.py
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
    """Deep merge two dictionaries.

    - If value is a dict, recurse
    - If value is a list, extend base list with override list
    - Otherwise, override the value

    Args:
        base: Base dictionary
        override: Override dictionary

    Returns:
        Merged dictionary
    """
    result = base.copy()
    for key, value in override.items():
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = deep_merge(result[key], value)
        elif (
            key in result and isinstance(result[key], list) and isinstance(value, list)
        ):
            combined = result[key] + value
            try:
                result[key] = list(dict.fromkeys(combined))
            except TypeError:
                seen = []
                for item in combined:
                    if item not in seen:
                        seen.append(item)
                result[key] = seen
        else:
            result[key] = value
    return result

get_exclusion_patterns(root)

Get file patterns to exclude from builds.

Combines default patterns with .gitignore if present.

Parameters:

Name Type Description Default
root Path

Project root directory

required

Returns:

Type Description
set[str]

Set of glob patterns to exclude

Source code in src/polytropos/primitives.py
def get_exclusion_patterns(root: Path) -> set[str]:
    """Get file patterns to exclude from builds.

    Combines default patterns with .gitignore if present.

    Args:
        root: Project root directory

    Returns:
        Set of glob patterns to exclude
    """
    DEFAULT_EXCLUDES = {
        ".git",
        ".github",
        "__pycache__",
        ".eggs",
        "*.pyc",
        "*.pyo",
        ".pytest_cache",
        ".mypy_cache",
        ".ruff_cache",
        "node_modules",
    }

    patterns = DEFAULT_EXCLUDES.copy()

    gitignore = root / ".gitignore"
    if gitignore.exists():
        gitignore_spec = PathSpec.from_lines(
            "gitwildmatch", gitignore.read_text().splitlines()
        )
        gitignore_patterns = {p.pattern for p in gitignore_spec.patterns}  # type: ignore[attr-defined]
        return patterns | gitignore_patterns

    return patterns

get_odoo_release(config_settings=None, pyproject_data=None)

Get the Odoo release to use for building.

Resolution order: 1. config_settings["odoo_release"] 2. ODOO_RELEASE environment variable 3. odoo.release.series (if odoo installed) 4. tool.polytropos.default_odoo_release (if pyproject_data provided)

Parameters:

Name Type Description Default
config_settings dict[str, Any] | None

PEP 517 config_settings dictionary

None
pyproject_data dict[str, Any] | None

Parsed pyproject.toml (optional, for default fallback)

None

Returns:

Type Description
str

Odoo release string (e.g., "17.0")

Raises:

Type Description
ValueError

If release cannot be determined

Source code in src/polytropos/primitives.py
def get_odoo_release(
    config_settings: dict[str, Any] | None = None,
    pyproject_data: dict[str, Any] | None = None,
) -> str:
    """Get the Odoo release to use for building.

    Resolution order:
    1. config_settings["odoo_release"]
    2. ODOO_RELEASE environment variable
    3. odoo.release.series (if odoo installed)
    4. tool.polytropos.default_odoo_release (if pyproject_data provided)

    Args:
        config_settings: PEP 517 config_settings dictionary
        pyproject_data: Parsed pyproject.toml (optional, for default fallback)

    Returns:
        Odoo release string (e.g., "17.0")

    Raises:
        ValueError: If release cannot be determined
    """
    if config_settings and (release := config_settings.get("odoo_release")):
        return str(release)

    if release := os.environ.get("ODOO_RELEASE"):
        return release

    try:
        from odoo.release import series

        return str(series)
    except ImportError:
        logger.debug("Could not detect Odoo release from installed package")

    if pyproject_data:
        polytropos_config = pyproject_data.get("tool", {}).get("polytropos", {})
        if default_release := polytropos_config.get("default_odoo_release"):
            return str(default_release)

    raise ValueError(
        "Odoo release not specified. Use --config-settings odoo_release=19.0, "
        "set ODOO_RELEASE environment variable, set "
        "[tool.polytropos].default_odoo_release in pyproject.toml, or make "
        "the odoo package importable at build time."
    )

get_pyproject_data(root)

Load and parse pyproject.toml.

Parameters:

Name Type Description Default
root Path

Path to the project root

required

Returns:

Type Description
dict[str, Any]

Parsed pyproject.toml as dictionary

Raises:

Type Description
ValueError

If pyproject.toml not found

Source code in src/polytropos/primitives.py
def get_pyproject_data(root: Path) -> dict[str, Any]:
    """Load and parse pyproject.toml.

    Args:
        root: Path to the project root

    Returns:
        Parsed pyproject.toml as dictionary

    Raises:
        ValueError: If pyproject.toml not found
    """
    pyproject_path = root / "pyproject.toml"
    if not pyproject_path.exists():
        raise ValueError("pyproject.toml not found")

    with open(pyproject_path, "rb") as f:
        return cast(dict[str, Any], tomllib.load(f))

get_readme_info(pyproject_data, root=None, src_dir=None, module_name='')

Get readme file path, content type, and optionally description content.

Parameters:

Name Type Description Default
pyproject_data dict[str, Any]

Parsed pyproject.toml

required
root Path | None

Project root directory (optional, for reading description)

None
src_dir str | None

Source directory relative to root (optional)

None
module_name str

Module name for image path rewriting

''

Returns:

Type Description
tuple[str, str, str | None] | None

Tuple of (readme_filename, content_type, description) or None if not specified.

tuple[str, str, str | None] | None

Description is None if root not provided or readme is in src_dir.

Source code in src/polytropos/primitives.py
def get_readme_info(
    pyproject_data: dict[str, Any],
    root: Path | None = None,
    src_dir: str | None = None,
    module_name: str = "",
) -> tuple[str, str, str | None] | None:
    """Get readme file path, content type, and optionally description content.

    Args:
        pyproject_data: Parsed pyproject.toml
        root: Project root directory (optional, for reading description)
        src_dir: Source directory relative to root (optional)
        module_name: Module name for image path rewriting

    Returns:
        Tuple of (readme_filename, content_type, description) or None if not specified.
        Description is None if root not provided or readme is in src_dir.
    """
    project_data = pyproject_data.get("project", {})
    readme = project_data.get("readme")
    if not readme:
        return None

    if isinstance(readme, dict):
        readme_filename = readme.get("file", "")
        content_type = readme.get("content-type", "text/plain")
    else:
        readme_filename = str(readme)
        if readme_filename.lower().endswith(".md"):
            content_type = "text/markdown"
        elif readme_filename.lower().endswith(".rst"):
            content_type = "text/x-rst"
        else:
            content_type = "text/plain"

    description: str | None = None
    if root:
        description = _convert_readme_to_rst(
            root, readme_filename, src_dir, content_type, module_name
        )

    return (readme_filename, content_type, description)

get_src_dir(root)

Get the src directory from polytropos configuration.

Parameters:

Name Type Description Default
root Path

Path to the project root

required

Returns:

Type Description
str | None

The src directory path (e.g., "src") or None if not configured

Source code in src/polytropos/primitives.py
def get_src_dir(root: Path) -> str | None:
    """Get the src directory from polytropos configuration.

    Args:
        root: Path to the project root

    Returns:
        The src directory path (e.g., "src") or None if not configured
    """
    pyproject_data = get_pyproject_data(root)
    polytropos_config = pyproject_data.get("tool", {}).get("polytropos", {})
    src_dir = polytropos_config.get("src")
    return str(src_dir) if src_dir is not None else None

matches_release_specifier(release_version, specifier)

Check if release matches the given PEP 440 specifier.

Parameters:

Name Type Description Default
release_version Version

Parsed Version object

required
specifier str | None

PEP 440 version specifier (e.g., ">=18, <20")

required

Returns:

Type Description
bool

True if release matches specifier

Source code in src/polytropos/primitives.py
def matches_release_specifier(release_version: Version, specifier: str | None) -> bool:
    """Check if release matches the given PEP 440 specifier.

    Args:
        release_version: Parsed Version object
        specifier: PEP 440 version specifier (e.g., ">=18, <20")

    Returns:
        True if release matches specifier
    """
    if specifier is None or specifier == "":
        return True
    try:
        spec = SpecifierSet(specifier)
        return release_version in spec
    except (ValueError, TypeError) as exc:
        logger.debug("Invalid version specifier '%s': %s", specifier, exc)
        return False

parse_file_maps(pyproject_data, odoo_release)

Parse [[tool.polytropos.files]] entries filtered by release.

Each entry maps versioned source content into a destination directory. Only entries matching the current Odoo release are returned.

Parameters:

Name Type Description Default
pyproject_data dict[str, Any]

Parsed pyproject.toml

required
odoo_release str

Odoo release string (e.g., "17.0")

required

Returns:

Type Description
list[dict[str, Any]]

List of matching file map entries, each with "src_files"/"src_dir" and "dst_dir" keys

Source code in src/polytropos/primitives.py
def parse_file_maps(
    pyproject_data: dict[str, Any], odoo_release: str
) -> list[dict[str, Any]]:
    """Parse `[[tool.polytropos.files]]` entries filtered by release.

    Each entry maps versioned source content into a destination directory.
    Only entries matching the current Odoo release are returned.

    Args:
        pyproject_data: Parsed pyproject.toml
        odoo_release: Odoo release string (e.g., "17.0")

    Returns:
        List of matching file map entries, each with "src_files"/"src_dir" and "dst_dir" keys
    """
    files_config = pyproject_data.get("tool", {}).get("polytropos", {}).get("files", [])
    if not isinstance(files_config, list):
        raise ValueError(
            "[tool.polytropos.files] must be a list, if defined. "
            "Use [[tool.polytropos.files]] (double brackets) in pyproject.toml."
        )
    parsed_release = parse_release(odoo_release)
    result: list[dict[str, Any]] = []
    for entry in files_config:
        releases_spec = entry.get("releases")
        if releases_spec is not None and not matches_release_specifier(
            parsed_release, releases_spec
        ):
            continue
        result.append(entry)
    return result

parse_release(release)

Parse release string to Version object.

Parameters:

Name Type Description Default
release str

Release string (e.g., "17.0")

required

Returns:

Type Description
Version

Version object

Source code in src/polytropos/primitives.py
def parse_release(release: str) -> Version:
    """Parse release string to Version object.

    Args:
        release: Release string (e.g., "17.0")

    Returns:
        Version object
    """
    normalized = release
    if "." not in normalized:
        normalized = f"{normalized}.0"
    return Version(normalized)