Skip to content

File

file

Filesystem helpers for path manipulation, file I/O, symlink management, and addon discovery.

Sections
  • Path utilities: path predicates, canonical path computation, prefix checks
  • File I/O: plain-text file reading/writing and directory copy
  • Symlinks: listing, mapping, rewriting, and materialising symlinks
  • Addons: locating and collecting Odoo addon directories

Functions:

Name Description
build_compose

Render a docker-compose.yml file from the project template.

check_prefix

Check whether a path is equal to or descends from a prefix directory.

collect_addon_paths

Collect (addon_path, unported) pairs from an addons directory.

copytree

Copy a directory tree from src to dst, preserving symlinks.

create_symlink

Create a symlink at the repo root pointing to an addon directory.

desired_path

Build the desired local path for a git repository URL.

ensure_parent

Ensure the parent directory of a path exists, creating it if needed.

file_updater

Update a file with new content, either replacing the entire file or a section between tags.

find_addon_dirs

Return all addon directories found under a root path.

find_addons

Yield AddonInfo for every Odoo addon found under a root directory.

find_modified_addons

Return the names of addons containing any of the given file paths.

get_addons_diff

Classify addon changes between base_ref and HEAD into new, updated, and removed.

get_excluded_addon_names

Return addon names that should be excluded from pre-commit checks.

get_filtered_addon_names

Return names of owned, installable, non-symlinked addons.

get_odoo_sources_dirs

Resolve the community and enterprise source directories for a given Odoo version.

get_requirements_diff

Compare the current requirements file against Python deps declared in addon manifests.

get_symlink_complete_map

Return a mapping of symlink parent dirs to all their target names.

get_symlink_map

Build a mapping of symlink parent directories to their single target name.

is_dir_empty

Check whether a directory exists and contains no entries.

is_pull_request_path

Detect whether a submodule path looks like a pull request path.

list_symlinks

Collect symlink targets found recursively under a directory.

make_migration_command

Build the content of a migration shell script from addon change lists.

materialize_symlink

Replace a symlink pointing to a directory with a physical copy of its target.

parse_odoo_version

Read and parse the Odoo version file into structured image information.

parse_packages

Read and return the sorted list of packages from the project packages file.

parse_requirements

Read and return the sorted list of entries from the project requirements file.

parse_text_file

Parse a text file's content into a list of non-empty, stripped lines.

read_and_parse

Read a text file and return its non-empty, sorted lines.

read_tagged_block

Return the raw content between start_tag and end_tag in a file.

relpath

Compute a relative path from one location to another.

rewrite_symlink

Rewrite a symlink's target by replacing a path prefix.

volume_prefix

Derive a Docker-safe volume prefix from the repo directory name.

write_migration_script

Write a migration script to the configured file path and mark it executable.

write_text_file

Write a list of lines to a text file.

build_compose

Render a docker-compose.yml file from the project template.

Parameters:

Name Type Description Default

odoo_version

float

Numeric Odoo major version (e.g. 17.0). Used to select the appropriate Postgres image (pgvector for 19+, plain postgres for earlier versions).

required

image

str

Full Docker image reference for the Odoo service (e.g. registry/odoo:17.0).

required

port

int

Host port to map to Odoo's internal port 8069.

required

prefix

str

Docker-safe volume name prefix, typically derived from the repo name.

required

dev

bool

Whether to append --dev=all to the Odoo command.

required

with_maildev

bool

Include the maildev SMTP catch-all service.

required

with_sftp

bool

Include the SFTP service.

required

Returns:

Type Description
str

Rendered docker-compose.yml content as a string, ready to write to disk.

Source code in src/oops/io/file.py
def build_compose(
    odoo_version: float,
    image: str,
    port: int,
    prefix: str,
    dev: bool,
    with_maildev: bool,
    with_sftp: bool,
) -> str:
    """Render a docker-compose.yml file from the project template.

    Args:
        odoo_version: Numeric Odoo major version (e.g. ``17.0``). Used to select
            the appropriate Postgres image (``pgvector`` for 19+, plain ``postgres``
            for earlier versions).
        image: Full Docker image reference for the Odoo service (e.g. ``registry/odoo:17.0``).
        port: Host port to map to Odoo's internal port 8069.
        prefix: Docker-safe volume name prefix, typically derived from the repo name.
        dev: Whether to append ``--dev=all`` to the Odoo command.
        with_maildev: Include the maildev SMTP catch-all service.
        with_sftp: Include the SFTP service.

    Returns:
        Rendered docker-compose.yml content as a string, ready to write to disk.
    """
    return COMPOSE_TEMPLATE.format(
        image=image,
        port=port,
        prefix=prefix,
        dev_flag="" if not dev else " --dev=all",
        postgres_image="pgvector/pgvector:pg16" if odoo_version >= 19 else "postgres:16.0",
        maildev_env=MAILDEV_ENV if with_maildev else "",
        maildev_service=MAILDEV_SERVICE if with_maildev else "",
        sftp_service=SFTP_SERVICE if with_sftp else "",
    )

check_prefix

check_prefix(path: str, prefix: str) -> bool

Check whether a path is equal to or descends from a prefix directory.

Parameters:

Name Type Description Default

path

str

Path to test.

required

prefix

str

Ancestor path to check against.

required

Returns:

Type Description
bool

True if path equals prefix or is nested inside it, False otherwise.

Source code in src/oops/io/file.py
def check_prefix(path: str, prefix: str) -> bool:
    """Check whether a path is equal to or descends from a prefix directory.

    Args:
        path: Path to test.
        prefix: Ancestor path to check against.

    Returns:
        True if path equals prefix or is nested inside it, False otherwise.
    """

    try:
        p = Path(path).resolve()
        prefix_path = Path(prefix).resolve()

        return prefix_path in p.parents or p == prefix_path
    except FileNotFoundError:
        return False

collect_addon_paths

collect_addon_paths(addons_dir: Path) -> list

Collect (addon_path, unported) pairs from an addons directory.

Parameters:

Name Type Description Default

addons_dir

Path

Root addons directory to inspect.

required

Returns:

Type Description
list

Sorted list of (Path, bool) pairs where the bool indicates

list

whether the addon lives under the unported subdirectory.

Source code in src/oops/io/file.py
def collect_addon_paths(addons_dir: Path) -> list:
    """Collect (addon_path, unported) pairs from an addons directory.

    Args:
        addons_dir: Root addons directory to inspect.

    Returns:
        Sorted list of (Path, bool) pairs where the bool indicates
        whether the addon lives under the unported subdirectory.
    """
    paths = [(p, False) for p in addons_dir.iterdir()]
    unported = addons_dir / UNPORTED_DIR
    if unported.is_dir():
        paths += [(p, True) for p in unported.iterdir()]
    return sorted(paths, key=lambda x: x[0])

copytree

copytree(src: Path, dst: Path, ignore_git: bool = True) -> None

Copy a directory tree from src to dst, preserving symlinks.

Parameters:

Name Type Description Default

src

Path

Source directory to copy.

required

dst

Path

Destination path, must not already exist.

required

ignore_git

bool

If True, skip .git directories. Defaults to True.

True
Source code in src/oops/io/file.py
def copytree(src: Path, dst: Path, ignore_git: bool = True) -> None:
    """Copy a directory tree from src to dst, preserving symlinks.

    Args:
        src: Source directory to copy.
        dst: Destination path, must not already exist.
        ignore_git: If True, skip .git directories. Defaults to True.
    """

    def _ignore(_dir, names):
        if not ignore_git:
            return set()
        return {n for n in names if n == ".git"}

    shutil.copytree(src, dst, symlinks=True, ignore=_ignore)
create_symlink(addon_dir: Path, repo_path: Path) -> Optional[str]

Create a symlink at the repo root pointing to an addon directory.

Skips creation if a file or symlink with the same name already exists at the repo root, printing a warning in that case.

Parameters:

Name Type Description Default

addon_dir

Path

Path to the addon directory to link.

required

repo_path

Path

Repository root where the symlink will be created.

required

Returns:

Type Description
Optional[str]

The symlink name (stem of addon_dir) if created, or None if skipped.

Source code in src/oops/io/file.py
def create_symlink(
    addon_dir: Path,
    repo_path: Path,
) -> Optional[str]:
    """Create a symlink at the repo root pointing to an addon directory.

    Skips creation if a file or symlink with the same name already exists at
    the repo root, printing a warning in that case.

    Args:
        addon_dir: Path to the addon directory to link.
        repo_path: Repository root where the symlink will be created.

    Returns:
        The symlink name (stem of addon_dir) if created, or None if skipped.
    """
    link_name = addon_dir.name
    link_path = repo_path / link_name
    target_rel = relpath(repo_path, addon_dir)
    if link_path.exists() or link_path.is_symlink():
        click.echo(f"  [skip] {link_name} already exists")
        return None
    os.symlink(target_rel, link_path)

    return link_name

desired_path

desired_path(url: str, pull_request: bool = False, prefix: Optional[str] = None, suffix: Optional[str] = None) -> str

Build the desired local path for a git repository URL.

Produces <prefix>/<owner>/<repo>/<suffix>, inserting a pull-request segment after the prefix when pull_request is True.

Parameters:

Name Type Description Default

url

str

GitHub repository URL (HTTPS or SSH).

required

pull_request

bool

If True, insert the pull-request directory segment. Defaults to False.

False

prefix

Optional[str]

Optional path prefix prepended before the owner segment.

None

suffix

Optional[str]

Optional path segment appended after the repo name.

None

Returns:

Type Description
str

Relative filesystem path derived from the repository URL components.

Source code in src/oops/io/file.py
def desired_path(
    url: str,
    pull_request: bool = False,
    prefix: Optional[str] = None,
    suffix: Optional[str] = None,
) -> str:
    """Build the desired local path for a git repository URL.

    Produces `<prefix>/<owner>/<repo>/<suffix>`, inserting a pull-request
    segment after the prefix when pull_request is True.

    Args:
        url: GitHub repository URL (HTTPS or SSH).
        pull_request: If True, insert the pull-request directory segment. Defaults to False.
        prefix: Optional path prefix prepended before the owner segment.
        suffix: Optional path segment appended after the repo name.

    Returns:
        Relative filesystem path derived from the repository URL components.
    """

    _, owner, repo = parse_repository_url(url)
    if owner == "oca":
        owner = owner.upper()

    parts = [owner, repo]

    if pull_request:
        parts.insert(0, config.pull_request_dir)

    if prefix:
        parts.insert(0, prefix.rstrip("/"))

    if suffix:
        parts.append(suffix)

    return os.path.join(*parts)

ensure_parent

ensure_parent(path: Path)

Ensure the parent directory of a path exists, creating it if needed.

Parameters:

Name Type Description Default

path

Path

Path whose parent directory should be created.

required
Source code in src/oops/io/file.py
def ensure_parent(path: Path):
    """Ensure the parent directory of a path exists, creating it if needed.

    Args:
        path: Path whose parent directory should be created.
    """

    path.parent.mkdir(parents=True, exist_ok=True)

file_updater

file_updater(filepath: str, new_inner_content: str, start_tag: Optional[str] = None, end_tag: Optional[str] = None, padding: str = '\n', append_position: str | bool = 'bottom', dry_run: bool = False) -> bool

Update a file with new content, either replacing the entire file or a section between tags.

Parameters:

Name Type Description Default

filepath

str

Path to the file to update.

required

new_inner_content

str

New content to insert.

required

start_tag

Optional[str]

Start tag for targeted replacement (optional).

None

end_tag

Optional[str]

End tag for targeted replacement (optional).

None

padding

str

Padding to add around the new content (default: newline).

'\n'

append_position

str | bool

Where to insert the tagged block when tags are absent from the file. 'top' prepends, 'bottom' appends (default). False leaves the file untouched when tags are missing.

'bottom'

Returns:

Name Type Description
bool bool

True if the file was updated, False if no changes have been made.

Source code in src/oops/io/file.py
def file_updater(
    filepath: str,
    new_inner_content: str,
    start_tag: Optional[str] = None,
    end_tag: Optional[str] = None,
    padding: str = "\n",
    append_position: str | bool = "bottom",
    dry_run: bool = False,
) -> bool:
    """Update a file with new content, either replacing the entire file or a section between tags.

    Args:
        filepath: Path to the file to update.
        new_inner_content: New content to insert.
        start_tag: Start tag for targeted replacement (optional).
        end_tag: End tag for targeted replacement (optional).
        padding: Padding to add around the new content (default: newline).
        append_position: Where to insert the tagged block when tags are absent from the file.
            ``'top'`` prepends, ``'bottom'`` appends (default). ``False`` leaves the file
            untouched when tags are missing.

    Returns:
        bool: True if the file was updated, False if no changes have been made.
    """
    path = Path(filepath)
    if not path.exists():
        click.echo(f"File {filepath} does not exist, creating it...")

        if not dry_run:
            os.makedirs(path.parent, exist_ok=True)
            with open(filepath, "w") as new_file:
                if start_tag and end_tag:
                    new_file.write(f"{start_tag}\n{new_inner_content}\n{end_tag}\n")

    if (start_tag and not end_tag) or (end_tag and not start_tag):
        raise ValueError(f"Targeted update for {filepath} requires BOTH start and end tags.")

    content = path.read_text()
    is_to_append = False

    # Case 1: Full File Replacement (missing tags).
    if not start_tag or not end_tag:
        new_file_content = new_inner_content.strip()

    # Case 2: Targeted Replacement (replace content between tags).
    else:
        start_esc = re.escape(start_tag)
        end_esc = re.escape(end_tag)
        # Capture optional leading whitespace to preserve indentation
        pattern = rf"([ \t]*{start_esc}).*?([ \t]*{end_esc})"

        match = re.search(pattern, content, flags=re.DOTALL)
        if match:
            replacement = f"\\1{padding}{new_inner_content}{padding}\\2"
            new_file_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
        elif append_position:
            # Content adding if not found.
            new_file_content = f"{start_tag}{padding}{new_inner_content}{padding}{end_tag}"
            is_to_append = True
        else:
            print_warning(f"Tags not found in {filepath} and append_position is False, skipping update.")
            return False

    if new_file_content != content:
        click.echo(f"Updating {filepath}...")
        if dry_run:
            click.echo("[dry-run]: \n" + new_file_content)
            return True

        if is_to_append:
            current_content = path.read_text()
            if append_position == "top":
                new_file_content = f"{new_file_content}\n{current_content}\n"
            else:
                new_file_content = f"{current_content}\n{new_file_content}\n"
            path.write_text(new_file_content)
        else:
            path.write_text(new_file_content + "\n")
        return True

    click.echo(f"No changes detected in {filepath}, skipping update.")
    return False

find_addon_dirs

find_addon_dirs(root: Path, with_pr: bool = False) -> list

Return all addon directories found under a root path.

Parameters:

Name Type Description Default

root

Path

Directory to search recursively.

required

with_pr

bool

If True, descend into pull-request subdirectories. Defaults to False.

False

Returns:

Type Description
list

List of Path objects for each directory containing a manifest file.

Source code in src/oops/io/file.py
def find_addon_dirs(root: Path, with_pr: bool = False) -> list:
    """Return all addon directories found under a root path.

    Args:
        root: Directory to search recursively.
        with_pr: If True, descend into pull-request subdirectories. Defaults to False.

    Returns:
        List of Path objects for each directory containing a manifest file.
    """
    addons = []
    for dirpath, dirnames, filenames in os.walk(root):
        if ".git" in dirnames:
            dirnames.remove(".git")
        if not with_pr and PR_DIR in dirnames:
            dirnames.remove(PR_DIR)
        if "__manifest__.py" in filenames or "__openerp__.py" in filenames:
            addons.append(Path(dirpath))
    return addons

find_addons

find_addons(root: Path, shallow: bool = False) -> Generator[AddonInfo, None, None]

Yield AddonInfo for every Odoo addon found under a root directory.

Parameters:

Name Type Description Default

root

Path

Directory to search recursively (symlinked first-level dirs are followed).

required

shallow

bool

If True, do not recurse deeper than one level into subdirectories. Defaults to False.

False

Yields:

Type Description
AddonInfo

AddonInfo for each addon directory containing a manifest file.

Source code in src/oops/io/file.py
def find_addons(root: Path, shallow: bool = False) -> Generator[AddonInfo, None, None]:
    """Yield AddonInfo for every Odoo addon found under a root directory.

    Args:
        root: Directory to search recursively (symlinked first-level dirs are followed).
        shallow: If True, do not recurse deeper than one level into subdirectories.
            Defaults to False.

    Yields:
        AddonInfo for each addon directory containing a manifest file.
    """

    root_parts = root.resolve().parts

    # followlinks=True lets us enter first-level *symlinked* directories
    for dirpath, dirnames, filenames in os.walk(root, followlinks=True):
        # skip VCS noise
        if ".git" in dirnames:
            dirnames.remove(".git")

        if "setup" in dirnames:
            dirnames.remove("setup")  # don't enter setup/ subdir

        # found an addon here?
        if "__manifest__.py" in filenames or "__openerp__.py" in filenames:
            manifest = load_manifest(Path(dirpath))
            yield AddonInfo.from_path(Path(dirpath), root_path=root, manifest=manifest)

        if shallow:
            depth = len(Path(dirpath).resolve().parts) - len(root_parts)
            if depth >= 1:
                # we're already in a first-level subdir (real or symlink) → don't go deeper
                dirnames[:] = []

find_modified_addons

find_modified_addons(files: list) -> list

Return the names of addons containing any of the given file paths.

Walks up each file path until a directory with an Odoo manifest is found.

Parameters:

Name Type Description Default

files

list

List of file paths to inspect.

required

Returns:

Type Description
list

Sorted list of addon directory names that contain at least one of the files.

Source code in src/oops/io/file.py
def find_modified_addons(files: list) -> list:
    """Return the names of addons containing any of the given file paths.

    Walks up each file path until a directory with an Odoo manifest is found.

    Args:
        files: List of file paths to inspect.

    Returns:
        Sorted list of addon directory names that contain at least one of the files.
    """
    addons = set()
    for f in files:
        p = Path(f)
        # Go back up the tree until you find a manifest
        for parent in [p] + list(p.parents):
            if (parent / "__manifest__.py").exists() or (parent / "__openerp__.py").exists():
                addons.add(str(parent.name))
                break
    return sorted(addons)

get_addons_diff

get_addons_diff(repo: Repo, base_ref: str) -> tuple[list, list, list]

Classify addon changes between base_ref and HEAD into new, updated, and removed.

Parameters:

Name Type Description Default

repo

Repo

GitPython Repo object for the local repository.

required

base_ref

str

Git ref (tag, branch, or commit-ish) to compare against HEAD.

required

Returns:

Type Description
list

Tuple of (new_addons, updated_addons, removed_addons), each a sorted list

list

of addon names.

Source code in src/oops/io/file.py
def get_addons_diff(repo: Repo, base_ref: str) -> tuple[list, list, list]:
    """Classify addon changes between base_ref and HEAD into new, updated, and removed.

    Args:
        repo: GitPython Repo object for the local repository.
        base_ref: Git ref (tag, branch, or commit-ish) to compare against HEAD.

    Returns:
        Tuple of (new_addons, updated_addons, removed_addons), each a sorted list
        of addon names.
    """
    # Newly added root-level entries (new symlinks or addon folders)
    added_files = repo.git.diff("--name-only", "--diff-filter=A", base_ref, "HEAD").splitlines()
    new_addons = set(find_modified_addons(added_files))

    # Removed root-level entries: verify each had a manifest at base_ref
    deleted_root = [
        f for f in repo.git.diff("--name-only", "--diff-filter=D", base_ref, "HEAD").splitlines() if "/" not in f
    ]
    removed_addons = []
    for name in deleted_root:
        try:
            repo.git.show(f"{base_ref}:{name}/__manifest__.py")
            removed_addons.append(name)
        except Exception:
            pass
    removed_addons = sorted(removed_addons)

    # All changed files across the main repo and submodules
    diff_files = repo.git.diff("--name-only", base_ref, "HEAD").splitlines()
    for sm in repo.submodules:
        subrepo = sm.module()

        old_sha = get_submodule_sha(repo, base_ref, str(sm.path))
        new_sha = get_submodule_sha(repo, "HEAD", str(sm.path))

        # The submodule has not changed between base_ref and HEAD.
        if not old_sha or not new_sha or old_sha == new_sha:
            continue

        sub_diff = subrepo.git.diff("--name-only", old_sha, new_sha).splitlines()
        diff_files.extend(f"{sm.path}/{f}" for f in sub_diff)

    all_addons = set(find_modified_addons(diff_files))
    updated_addons = all_addons - new_addons

    return sorted(new_addons), sorted(updated_addons), sorted(removed_addons)

get_excluded_addon_names

get_excluded_addon_names(repo_path: Path) -> list

Return addon names that should be excluded from pre-commit checks.

An addon is excluded when it is not installable or its author does not match config.manifest.author (i.e. it is a third-party addon).

Parameters:

Name Type Description Default

repo_path

Path

Root directory of the local repository.

required

Returns:

Type Description
list

Sorted list of technical addon names to exclude.

Source code in src/oops/io/file.py
def get_excluded_addon_names(repo_path: Path) -> list:
    """Return addon names that should be excluded from pre-commit checks.

    An addon is excluded when it is not installable or its author does not
    match ``config.manifest.author`` (i.e. it is a third-party addon).

    Args:
        repo_path: Root directory of the local repository.

    Returns:
        Sorted list of technical addon names to exclude.
    """
    res = []
    for addon in find_addons(repo_path, shallow=True):
        if not addon.installable or config.manifest.author.lower() not in addon.author.lower():
            res.append(addon.technical_name)
    return sorted(res)

get_filtered_addon_names

get_filtered_addon_names(repo_path: Path) -> list

Return names of owned, installable, non-symlinked addons.

Selects addons that are directly in the repository (not symlinks to third-party modules), are installable, and are authored by config.manifest.author. Intended as the default scope for manifest lint and fix commands.

Parameters:

Name Type Description Default

repo_path

Path

Root directory of the local repository.

required

Returns:

Type Description
list

Sorted list of technical addon names matching the criteria.

Source code in src/oops/io/file.py
def get_filtered_addon_names(repo_path: Path) -> list:
    """Return names of owned, installable, non-symlinked addons.

    Selects addons that are directly in the repository (not symlinks to
    third-party modules), are installable, and are authored by
    ``config.manifest.author``. Intended as the default scope for manifest
    lint and fix commands.

    Args:
        repo_path: Root directory of the local repository.

    Returns:
        Sorted list of technical addon names matching the criteria.
    """
    res = []
    for addon in find_addons(repo_path, shallow=True):
        if not addon.symlink and addon.installable and config.manifest.author.lower() in addon.author.lower():
            res.append(addon.technical_name)
    return sorted(res)

get_odoo_sources_dirs

get_odoo_sources_dirs(version: str, base_dir: Optional[Path] = None) -> tuple[Path, Path]

Resolve the community and enterprise source directories for a given Odoo version.

The base directory is taken from base_dir when provided, otherwise falls back to odoo.sources_dir in ~/.oops.yaml. The version sub-directory is created if it does not exist yet.

Parameters:

Name Type Description Default

version

str

Odoo version string used as the sub-directory name (e.g. "17.0").

required

base_dir

Optional[Path]

Optional explicit root for Odoo sources. Overrides the config value.

None

Returns:

Type Description
Path

A (community_dir, enterprise_dir) tuple of :class:~pathlib.Path objects

Path

pointing to the community and enterprise sub-directories under

tuple[Path, Path]

<base_dir>/<version>/. The paths are returned regardless of whether they

tuple[Path, Path]

exist on disk — the caller is responsible for checking existence.

Raises:

Type Description
UsageError

When neither base_dir nor odoo.sources_dir is set.

Source code in src/oops/io/file.py
def get_odoo_sources_dirs(version: str, base_dir: Optional[Path] = None) -> tuple[Path, Path]:
    """Resolve the community and enterprise source directories for a given Odoo version.

    The base directory is taken from ``base_dir`` when provided, otherwise falls back
    to ``odoo.sources_dir`` in ``~/.oops.yaml``. The version sub-directory is created
    if it does not exist yet.

    Args:
        version: Odoo version string used as the sub-directory name (e.g. ``"17.0"``).
        base_dir: Optional explicit root for Odoo sources. Overrides the config value.

    Returns:
        A ``(community_dir, enterprise_dir)`` tuple of :class:`~pathlib.Path` objects
        pointing to the ``community`` and ``enterprise`` sub-directories under
        ``<base_dir>/<version>/``.  The paths are returned regardless of whether they
        exist on disk — the caller is responsible for checking existence.

    Raises:
        click.UsageError: When neither ``base_dir`` nor ``odoo.sources_dir`` is set.
    """
    resolved = base_dir or config.odoo.sources_dir
    if resolved is None:
        raise click.UsageError("No base directory provided. Set odoo.sources_dir in ~/.oops.yaml.")
    target = resolved / version
    target.mkdir(parents=True, exist_ok=True)

    community_dir = target / "community"
    enterprise_dir = target / "enterprise"

    return community_dir, enterprise_dir

get_requirements_diff

get_requirements_diff(repo_path: Path) -> tuple[bool, list, list]

Compare the current requirements file against Python deps declared in addon manifests.

Collects all python entries from external_dependencies across every addon found in repo_path, sorts them, and runs a line-level diff against the existing requirement_file.

Parameters:

Name Type Description Default

repo_path

Path

Root of the repository to scan for addons.

required

Returns:

Type Description
bool

A three-element tuple (has_changes, new_lines, diff):

list
  • has_changes: True if the new content differs from the current file.
list
  • new_lines: Sorted list of dependency lines to write.
tuple[bool, list, list]
  • diff: Raw output from :func:difflib.ndiff.
Source code in src/oops/io/file.py
def get_requirements_diff(repo_path: Path) -> tuple[bool, list, list]:
    """Compare the current requirements file against Python deps declared in addon manifests.

    Collects all ``python`` entries from ``external_dependencies`` across every
    addon found in *repo_path*, sorts them, and runs a line-level diff against
    the existing *requirement_file*.

    Args:
        repo_path: Root of the repository to scan for addons.

    Returns:
        A three-element tuple ``(has_changes, new_lines, diff)``:

        - ``has_changes``: True if the new content differs from the current file.
        - ``new_lines``: Sorted list of dependency lines to write.
        - ``diff``: Raw output from :func:`difflib.ndiff`.
    """
    raw_dependencies = _collect_raw_deps(repo_path, config.requirements.python_requirements_mapping)
    requirement_versions = _group_deps_by_operator(sorted(raw_dependencies))

    # Merge range constraints per package into the tightest floor + ceil.
    final_deps = []
    for dep_name, ops in requirement_versions.items():
        floor_val = _get_version_boundary([">", ">="], ">", ops, is_for_floor=True)
        ceil_val = _get_version_boundary(["<", "<="], "<", ops, is_for_floor=False)
        if floor_val and ceil_val:
            final_deps.append(f"{dep_name}{floor_val},{ceil_val}")
        elif floor_val:
            final_deps.append(f"{dep_name}{floor_val}")
        elif ceil_val:
            final_deps.append(f"{dep_name}{ceil_val}")

    # Add unresolved deps as-is (bare names and == pins).
    # == pins are always kept even when range constraints exist — human arbitration required.
    resolved_names = set(requirement_versions.keys())
    for dep in sorted(raw_dependencies):
        dep_name = re.split("[<=>]", dep)[0].strip()
        is_eq_pin = bool(re.match(r"[^<>=!]*==", dep))
        if dep_name not in resolved_names or is_eq_pin:
            final_deps.append(dep)

    final_deps = sorted(final_deps)

    current_reqs = parse_requirements(repo_path)
    diff = list(difflib.ndiff(current_reqs, final_deps))
    has_changes = any(line.startswith(("-", "+")) for line in diff)
    return has_changes, ["# generated from manifests external_dependencies"] + final_deps, diff
get_symlink_complete_map(path: str) -> dict

Return a mapping of symlink parent dirs to all their target names.

Parameters:

Name Type Description Default
str

Root directory to scan for symlinks.

required

Returns:

Type Description
dict

Dict mapping each parent directory path to a list of target names

dict

found under it.

Source code in src/oops/io/file.py
def get_symlink_complete_map(path: str) -> dict:
    """Return a mapping of symlink parent dirs to all their target names.

    Args:
        path: Root directory to scan for symlinks.

    Returns:
        Dict mapping each parent directory path to a list of target names
        found under it.
    """
    res = {}

    for t in list_symlinks(Path(path)):
        res.setdefault(str(Path(t).parent), []).append(Path(t).name)

    return res
get_symlink_map(path: str) -> dict

Build a mapping of symlink parent directories to their single target name.

Parameters:

Name Type Description Default
str

Root directory to scan for symlinks.

required

Returns:

Type Description
dict

Dict mapping each parent directory path to one target name.

dict

Assumes at most one symlink per parent directory.

Source code in src/oops/io/file.py
def get_symlink_map(path: str) -> dict:
    """Build a mapping of symlink parent directories to their single target name.

    Args:
        path: Root directory to scan for symlinks.

    Returns:
        Dict mapping each parent directory path to one target name.
        Assumes at most one symlink per parent directory.
    """

    # FIXME: assume there is only one symlink per submodule for now
    return {str(Path(t).parent): Path(t).name for t in list_symlinks(Path(path))}

is_dir_empty

is_dir_empty(p: Path) -> bool

Check whether a directory exists and contains no entries.

Parameters:

Name Type Description Default

p

Path

Path to the directory to check.

required

Returns:

Type Description
bool

True if the directory exists and is empty, False otherwise.

Source code in src/oops/io/file.py
def is_dir_empty(p: Path) -> bool:
    """Check whether a directory exists and contains no entries.

    Args:
        p: Path to the directory to check.

    Returns:
        True if the directory exists and is empty, False otherwise.
    """

    try:
        return p.is_dir() and not any(p.iterdir())
    except FileNotFoundError:
        return False

is_pull_request_path

is_pull_request_path(raw: Optional[str]) -> bool

Detect whether a submodule path looks like a pull request path.

Parameters:

Name Type Description Default

raw

Optional[str]

Submodule path string to inspect.

required

Returns:

Type Description
bool

True if the path matches pull-request naming conventions, False otherwise.

Source code in src/oops/io/file.py
def is_pull_request_path(raw: Optional[str]) -> bool:
    """Detect whether a submodule path looks like a pull request path.

    Args:
        raw: Submodule path string to inspect.

    Returns:
        True if the path matches pull-request naming conventions, False otherwise.
    """

    if not raw:
        return False

    return raw.startswith(f"{PR_DIR}/") or "pr" in raw.split("/")
list_symlinks(path: PathLike, broken_only: bool = False) -> list[str]

Collect symlink targets found recursively under a directory.

Parameters:

Name Type Description Default

path

PathLike

Root directory to walk.

required

broken_only

bool

If True, only return targets of broken symlinks. Defaults to False.

False

Returns:

Type Description
list[str]

List of symlink target strings found under path.

Source code in src/oops/io/file.py
def list_symlinks(path: PathLike, broken_only: bool = False) -> list[str]:
    """Collect symlink targets found recursively under a directory.

    Args:
        path: Root directory to walk.
        broken_only: If True, only return targets of broken symlinks. Defaults to False.

    Returns:
        List of symlink target strings found under path.
    """

    targets = []
    for root, dirs, files in os.walk(path):
        if ".git" in dirs:
            dirs.remove(".git")
        for n in dirs + files:
            p = Path(root) / n
            if p.is_symlink():
                if broken_only and not p.exists():
                    targets.append(os.readlink(p))
                elif not broken_only:
                    with contextlib.suppress(OSError):
                        targets.append(os.readlink(p))

    return targets

make_migration_command

make_migration_command(new_addons: Optional[list] = None, updated_addons: Optional[list] = None, removed_addons: Optional[list] = None, release: Optional[str] = None) -> str

Build the content of a migration shell script from addon change lists.

Parameters:

Name Type Description Default

new_addons

Optional[list]

Addons to install with -i.

None

updated_addons

Optional[list]

Addons to update with -u.

None

removed_addons

Optional[list]

Addons that were removed; included as a comment only.

None

release

Optional[str]

Release label used in the script header. Defaults to "Unreleased".

None

Returns:

Type Description
str

Full migration script content as a string, including the shebang line.

Source code in src/oops/io/file.py
def make_migration_command(
    new_addons: Optional[list] = None,
    updated_addons: Optional[list] = None,
    removed_addons: Optional[list] = None,
    release: Optional[str] = None,
) -> str:
    """Build the content of a migration shell script from addon change lists.

    Args:
        new_addons: Addons to install with ``-i``.
        updated_addons: Addons to update with ``-u``.
        removed_addons: Addons that were removed; included as a comment only.
        release: Release label used in the script header. Defaults to "Unreleased".

    Returns:
        Full migration script content as a string, including the shebang line.
    """

    # TODO: check content
    remove_command = "# Removed addons (manual action required): {addons}"
    install_command = "odoo --stop-after-init --no-http -i {addons}"
    update_command = "odoo --stop-after-init --no-http -u {addons}"
    template: str = "#!/bin/bash\n\n# [{release}] migration script\n{body}\n"
    commands = []

    if removed_addons:
        commands.append(remove_command.format(addons=",".join(sorted(removed_addons))))
    if new_addons:
        commands.append(install_command.format(addons=",".join(sorted(new_addons))))
    if updated_addons:
        commands.append(update_command.format(addons=",".join(sorted(updated_addons))))

    return template.format(body="\n".join(commands), release=release or "Unreleased")
materialize_symlink(symlink_path: Path, dry_run: bool) -> None

Replace a symlink pointing to a directory with a physical copy of its target.

Parameters:

Name Type Description Default
Path

Path to the symlink to materialize.

required

dry_run

bool

If True, validate inputs but make no filesystem changes.

required

Raises:

Type Description
ValueError

If the path does not exist, is not a symlink, its target is not a directory, or materialization fails.

Source code in src/oops/io/file.py
def materialize_symlink(symlink_path: Path, dry_run: bool) -> None:
    """Replace a symlink pointing to a directory with a physical copy of its target.

    Args:
        symlink_path: Path to the symlink to materialize.
        dry_run: If True, validate inputs but make no filesystem changes.

    Raises:
        ValueError: If the path does not exist, is not a symlink, its target
            is not a directory, or materialization fails.
    """

    if not symlink_path.exists():
        raise ValueError(f"Path not found: {symlink_path}")
    if not symlink_path.is_symlink():
        raise ValueError(f"Not a symlink: {symlink_path}")

    target = symlink_path.resolve(strict=True)
    if not target.is_dir():
        raise ValueError(f"Symlink target is not a directory: {target}")

    parent = symlink_path.parent
    tmp = parent / f".{symlink_path.name}.__oops_materialize_tmp__"

    if tmp.exists():
        raise ValueError(f"Temporary path already exists: {tmp}")

    logging.debug(f"[oops] materialize: {symlink_path} -> {target}")
    logging.debug(f"[oops] tmp copy:   {tmp}")

    if dry_run:
        return

    try:
        copytree(target, tmp)
        # Remove the symlink and atomically replace with the copied tree
        symlink_path.unlink()
        os.replace(tmp, symlink_path)  # atomic on same filesystem
    except Exception as e:
        # Cleanup tmp on failure
        with contextlib.suppress(Exception):
            if tmp.exists():
                shutil.rmtree(tmp)
        raise ValueError(f"Failed to materialize {symlink_path}: {e}") from e

parse_odoo_version

parse_odoo_version(path: Path) -> ImageInfo

Read and parse the Odoo version file into structured image information.

Reads the first non-empty line of the version file and parses it as a Docker image tag, extracting the major version, edition, registry, release date, and flags.

Parameters:

Name Type Description Default

path

Path

Project root directory containing the Odoo version file.

required

Returns:

Type Description
ImageInfo

ImageInfo populated with registry, major version, edition, release date, and flags.

Raises:

Type Description
ValueError

If the version file is empty, missing, or the tag format is unrecognised.

Source code in src/oops/io/file.py
def parse_odoo_version(path: Path) -> ImageInfo:
    """Read and parse the Odoo version file into structured image information.

    Reads the first non-empty line of the version file and parses it as a Docker image
    tag, extracting the major version, edition, registry, release date, and flags.

    Args:
        path: Project root directory containing the Odoo version file.

    Returns:
        ImageInfo populated with registry, major version, edition, release date, and flags.

    Raises:
        ValueError: If the version file is empty, missing, or the tag format is unrecognised.
    """
    res = read_and_parse(path / config.project.file_odoo_version)
    if not res:
        raise ValueError()
    return parse_image_tag(res[0])

parse_packages

parse_packages(path: Path) -> list

Read and return the sorted list of packages from the project packages file.

Parameters:

Name Type Description Default

path

Path

Project root directory containing the packages file.

required

Returns:

Type Description
list

Sorted list of package names.

Source code in src/oops/io/file.py
def parse_packages(path: Path) -> list:
    """Read and return the sorted list of packages from the project packages file.

    Args:
        path: Project root directory containing the packages file.

    Returns:
        Sorted list of package names.
    """
    return read_and_parse(path / config.project.file_packages)

parse_requirements

parse_requirements(path: Path) -> list

Read and return the sorted list of entries from the project requirements file.

Parameters:

Name Type Description Default

path

Path

Project root directory containing the requirements file.

required

Returns:

Type Description
list

Sorted list of requirement strings, or an empty list if the file does not exist.

Source code in src/oops/io/file.py
def parse_requirements(path: Path) -> list:
    """Read and return the sorted list of entries from the project requirements file.

    Args:
        path: Project root directory containing the requirements file.

    Returns:
        Sorted list of requirement strings, or an empty list if the file does not exist.
    """
    req_file = path / config.project.file_requirements
    if not req_file.exists():
        return []
    return read_and_parse(req_file, unique=False)

parse_text_file

parse_text_file(content: str, unique: bool = True) -> list

Parse a text file's content into a list of non-empty, stripped lines.

Parameters:

Name Type Description Default

content

str

Raw file content as a string.

required

unique

bool

Whether to deduplicate the result. Defaults to True.

True

Returns:

Type Description
list

List of cleaned, non-empty lines.

Source code in src/oops/io/file.py
def parse_text_file(content: str, unique: bool = True) -> list:
    """Parse a text file's content into a list of non-empty, stripped lines.

    Args:
        content: Raw file content as a string.
        unique (bool): Whether to deduplicate the result. Defaults to True.

    Returns:
        List of cleaned, non-empty lines.
    """

    return filter_and_clean(content.splitlines(), unique)

read_and_parse

read_and_parse(path: Path, unique: bool = True) -> list[str]

Read a text file and return its non-empty, sorted lines.

Parameters:

Name Type Description Default

path

Path

Path to the text file to read.

required

unique

bool

Whether to deduplicate the result. Defaults to True.

True

Returns:

Type Description
list[str]

Sorted list of cleaned, non-empty lines from the file.

Source code in src/oops/io/file.py
def read_and_parse(path: Path, unique: bool = True) -> list[str]:
    """Read a text file and return its non-empty, sorted lines.

    Args:
        path: Path to the text file to read.
        unique (bool): Whether to deduplicate the result. Defaults to True.

    Returns:
        Sorted list of cleaned, non-empty lines from the file.
    """
    return sorted(parse_text_file(path.read_text(), unique))

read_tagged_block

read_tagged_block(filepath: Union[str, Path], start_tag: str, end_tag: str) -> str

Return the raw content between start_tag and end_tag in a file.

Parameters:

Name Type Description Default

filepath

Union[str, Path]

Path to the file to read.

required

start_tag

str

Exact string marking the beginning of the block.

required

end_tag

str

Exact string marking the end of the block.

required

Returns:

Type Description
str

The text between the two tags, or an empty string when the file does

str

not exist or the tags are not found.

Source code in src/oops/io/file.py
def read_tagged_block(filepath: Union[str, Path], start_tag: str, end_tag: str) -> str:
    """Return the raw content between start_tag and end_tag in a file.

    Args:
        filepath: Path to the file to read.
        start_tag: Exact string marking the beginning of the block.
        end_tag: Exact string marking the end of the block.

    Returns:
        The text between the two tags, or an empty string when the file does
        not exist or the tags are not found.
    """
    path = Path(filepath)
    if not path.exists():
        return ""
    m = re.search(re.escape(start_tag) + r"(.*?)" + re.escape(end_tag), path.read_text(), re.DOTALL)
    return m.group(1) if m else ""

relpath

relpath(from_path: Path, to_path: Path) -> str

Compute a relative path from one location to another.

Parameters:

Name Type Description Default

from_path

Path

The starting directory.

required

to_path

Path

The target path to reach.

required

Returns:

Type Description
str

Relative path string from from_path to to_path.

Source code in src/oops/io/file.py
def relpath(from_path: Path, to_path: Path) -> str:
    """Compute a relative path from one location to another.

    Args:
        from_path: The starting directory.
        to_path: The target path to reach.

    Returns:
        Relative path string from from_path to to_path.
    """

    return os.path.relpath(to_path, start=from_path)
rewrite_symlink(link: Path, old_prefix: str, new_prefix: str) -> bool

Rewrite a symlink's target by replacing a path prefix.

Parameters:

Name Type Description Default

link

Path

Path to the symlink to rewrite.

required

old_prefix

str

Prefix to replace in the symlink target.

required

new_prefix

str

Replacement prefix.

required

Returns:

Type Description
bool

True if the symlink was rewritten, False if the target did not match.

Source code in src/oops/io/file.py
def rewrite_symlink(link: Path, old_prefix: str, new_prefix: str) -> bool:
    """Rewrite a symlink's target by replacing a path prefix.

    Args:
        link: Path to the symlink to rewrite.
        old_prefix: Prefix to replace in the symlink target.
        new_prefix: Replacement prefix.

    Returns:
        True if the symlink was rewritten, False if the target did not match.
    """

    try:
        target = os.readlink(link)
    except OSError:
        return False
    if old_prefix in target:
        new_target = target.replace(old_prefix, new_prefix)
        link.unlink()
        os.symlink(new_target, link)
        return True
    return False

volume_prefix

volume_prefix(repo_path: Path) -> str

Derive a Docker-safe volume prefix from the repo directory name.

Strips a leading odoo- prefix (common convention) then replaces any non-alphanumeric character with an underscore.

Examples:

odoo-my-projectmy_project my-projectmy_project odoo-client_v2client_v2

Source code in src/oops/io/file.py
def volume_prefix(repo_path: Path) -> str:
    """Derive a Docker-safe volume prefix from the repo directory name.

    Strips a leading ``odoo-`` prefix (common convention) then
    replaces any non-alphanumeric character with an underscore.

    Examples:
        ``odoo-my-project`` → ``my_project``
        ``my-project``      → ``my_project``
        ``odoo-client_v2``  → ``client_v2``
    """
    name = repo_path.name
    if name.startswith("odoo-"):
        name = name[len("odoo-") :]
    return re.sub(r"[^a-z0-9]", "_", name.lower())

write_migration_script

write_migration_script(content: str, dry_run: bool = False) -> Optional[str]

Write a migration script to the configured file path and mark it executable.

Parameters:

Name Type Description Default

content

str

Full script content to write.

required

dry_run

bool

If True, print to stdout instead of writing to disk. Defaults to False.

False
Source code in src/oops/io/file.py
def write_migration_script(content: str, dry_run: bool = False) -> Optional[str]:
    """Write a migration script to the configured file path and mark it executable.

    Args:
        content: Full script content to write.
        dry_run: If True, print to stdout instead of writing to disk. Defaults to False.
    """
    import click  # noqa: PLC0415

    if dry_run:
        click.echo(content)
        return None

    with open(config.project.file_migrate, mode="w", encoding="UTF-8") as file:
        file.write(content)

    # Do a chmod +x
    st = os.stat(config.project.file_migrate)
    os.chmod(config.project.file_migrate, st.st_mode | 0o111)

    return config.project.file_migrate

write_text_file

write_text_file(path: Path, lines: list, new_line: str = '\n', add_final_newline: bool = True)

Write a list of lines to a text file.

Parameters:

Name Type Description Default

path

Path

Destination file path.

required

lines

list

Lines to write, joined by new_line.

required

new_line

str

Line separator. Defaults to "\n".

'\n'

add_final_newline

bool

If True, append a trailing newline. Defaults to True.

True
Source code in src/oops/io/file.py
def write_text_file(path: Path, lines: list, new_line: str = "\n", add_final_newline: bool = True):
    """Write a list of lines to a text file.

    Args:
        path: Destination file path.
        lines: Lines to write, joined by new_line.
        new_line: Line separator. Defaults to "\\n".
        add_final_newline: If True, append a trailing newline. Defaults to True.
    """
    content = new_line.join(lines)
    if add_final_newline:
        content += new_line
    path.write_text(content)