Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
docbuild.utils.git.ManagedGitRepo
=================================

.. py:class:: docbuild.utils.git.ManagedGitRepo(remote_url: str, rootdir: pathlib.Path, gitconfig: pathlib.Path | None = None)
.. py:class:: docbuild.utils.git.ManagedGitRepo(repo: str | docbuild.models.repo.Repo, rootdir: pathlib.Path, gitconfig: pathlib.Path | None = None)

Manages a bare repository and its temporary worktrees.

Expand Down
1 change: 1 addition & 0 deletions src/docbuild/cli/cmd_build/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def build(ctx: click.Context, doctypes: tuple[Doctype]) -> None:
"""
ctx.ensure_object(DocBuildContext)
context: DocBuildContext = ctx.obj
# env = cast(EnvConfig, context.envconfig)

click.echo(f"[BUILD] Verbosity: {context.verbose}")
click.echo(f"{context=}")
Expand Down
1 change: 1 addition & 0 deletions src/docbuild/cli/cmd_c14n/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ def c14n(ctx: click.Context) -> None:

:param ctx: The Click context object.
"""
# env = cast(EnvConfig, context.envconfig)
click.echo(f"[C17N] Verbosity: {ctx.obj.verbose}")
8 changes: 5 additions & 3 deletions src/docbuild/cli/cmd_metadata/metaprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
import logging
from pathlib import Path
import shlex
from typing import Any
from typing import Any, cast

from lxml import etree
from pydantic import ValidationError
from rich.console import Console

from ...config.xml.stitch import create_stitchfile
from ...constants import DEFAULT_DELIVERABLES
from ...models.config.env import EnvConfig
from ...models.deliverable import Deliverable
from ...models.doctype import Doctype
from ...models.manifest import Document, Manifest
Expand Down Expand Up @@ -407,12 +408,13 @@ async def process(
configured correctly.
:return: 0 if all files passed validation, 1 if any failures occurred.
"""
configdir = Path(context.envconfig.paths.config_dir).expanduser()
env = cast(EnvConfig, context.envconfig)
configdir = Path(env.paths.config_dir).expanduser()
stdout.print(f"Config path: {configdir}")
xmlconfigs = tuple(configdir.rglob("[a-z]*.xml"))
stitchnode: etree._ElementTree = await create_stitchfile(xmlconfigs)

tmp_metadata_dir = context.envconfig.paths.tmp.tmp_metadata_dir
tmp_metadata_dir = env.paths.tmp.tmp_metadata_dir
# TODO: Is this necessary here?
tmp_metadata_dir.mkdir(parents=True, exist_ok=True)

Expand Down
3 changes: 0 additions & 3 deletions src/docbuild/cli/cmd_repo/cmd_clone.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@ def clone(ctx: click.Context, repos: tuple[str, ...]) -> None:
:param ctx: The Click context object.
"""
context: DocBuildContext = ctx.obj
if context.envconfig is None:
raise ValueError("No envconfig found in context.")

result = asyncio.run(process(context, repos))
log.info(f"Clone process completed with exit code: {result}")
ctx.exit(result)
9 changes: 5 additions & 4 deletions src/docbuild/cli/cmd_repo/cmd_dir.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Show the directory path for permanent repositories."""

from typing import cast

import click

from ...cli.context import DocBuildContext
from ...models.config.env import EnvConfig


@click.command(help=__doc__, name="dir")
Expand All @@ -16,9 +19,7 @@ def cmd_dir(ctx: click.Context) -> None:
:param ctx: The Click context object.
"""
context: DocBuildContext = ctx.obj
if context.envconfig is None:
raise ValueError("No envconfig found in context.")

repo_dir = context.envconfig.get("paths", {}).get("repo_dir", None)
env = cast(EnvConfig, context.envconfig)
repo_dir = env.paths.repo_dir
print(repo_dir)
ctx.exit(0)
14 changes: 5 additions & 9 deletions src/docbuild/cli/cmd_repo/cmd_list.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""List the available permanent repositories."""

from pathlib import Path
from typing import cast

import click
from rich.console import Console

from ...cli.context import DocBuildContext
from ...models.config.env import EnvConfig

console = Console()
console_err = Console(stderr=True)
Expand All @@ -24,15 +26,9 @@ def cmd_list(ctx: click.Context) -> None:
:param ctx: The Click context object.
"""
context: DocBuildContext = ctx.obj
if context.envconfig is None:
raise ValueError("No envconfig found in context.")

repo_dir = context.envconfig.get("paths", {}).get("repo_dir", None)
if repo_dir is None:
raise ValueError(
"No permanent repositories defined, neither with "
"--env-config nor as default."
)
env = cast(EnvConfig, context.envconfig)

repo_dir = env.paths.repo_dir
repo_dir = Path(repo_dir).resolve()
if not repo_dir.exists():
console_err.print(
Expand Down
14 changes: 6 additions & 8 deletions src/docbuild/cli/cmd_repo/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import asyncio
import logging
from pathlib import Path
from typing import cast

from ...cli.context import DocBuildContext
from ...config.xml.stitch import create_stitchfile
from ...constants import GITLOGGER_NAME
from ...models.config.env import EnvConfig
from ...models.repo import Repo
from ...utils.contextmgr import make_timer
from ...utils.git import ManagedGitRepo
Expand All @@ -23,14 +25,9 @@ async def process(context: DocBuildContext, repos: tuple[str, ...]) -> int:
:raises ValueError: If configuration paths are missing.
"""
# The calling command function is expected to have checked context.envconfig.
paths = context.envconfig.get("paths", {})
config_dir_str = paths.get("config_dir")
repo_dir_str = paths.get("repo_dir")

if not config_dir_str:
raise ValueError("Could not get a value from envconfig.paths.config_dir")
if not repo_dir_str:
raise ValueError("Could not get a value from envconfig.paths.repo_dir")
envcfg = cast(EnvConfig, context.envconfig)
config_dir_str = envcfg.paths.config_dir
repo_dir_str = envcfg.paths.repo_dir

configdir = Path(config_dir_str).expanduser()
repo_dir = Path(repo_dir_str).expanduser()
Expand All @@ -52,6 +49,7 @@ async def process(context: DocBuildContext, repos: tuple[str, ...]) -> int:
else:
# Create a unique list from user input, preserving order
unique_git_repos = list(dict.fromkeys(Repo(r) for r in repos))
log.debug("User-specified repositories: %s", unique_git_repos)

if not unique_git_repos:
log.info("No repositories found to clone.")
Expand Down
14 changes: 4 additions & 10 deletions src/docbuild/cli/cmd_validate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
from collections.abc import Iterator
import logging
from pathlib import Path
from typing import cast

import click

from ...models.config.env import EnvConfig
from ..context import DocBuildContext
from . import process as process_mod

Expand Down Expand Up @@ -36,20 +38,12 @@ def validate(
:param validation_method: Validation method to use, 'jing' or 'lxml'.
"""
context: DocBuildContext = ctx.obj
env = cast(EnvConfig, context.envconfig)

# Set the chosen validation method in the context for downstream use
context.validation_method = validation_method.lower()

if context.envconfig is None:
raise ValueError("No envconfig found in context.")

if (paths := ctx.obj.envconfig.get("paths")) is None:
raise ValueError("No paths found in envconfig.")

configdir = paths.get("config_dir", None)
if configdir is None:
raise ValueError("Could not get a value from envconfig.paths.config_dir")

configdir = env.paths.config_dir
configdir_path = Path(configdir).expanduser()

if not xmlfiles:
Expand Down
14 changes: 11 additions & 3 deletions src/docbuild/utils/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,24 @@ def clear_cache(cls) -> None:
cls._is_updated.clear()

def __init__(
self: Self, remote_url: str, rootdir: Path, gitconfig: Path | None = None
self: Self, repo: str | Repo, rootdir: Path, gitconfig: Path | None = None
) -> None:
"""Initialize the managed repository.

:param remote_url: The remote URL of the repository.
:param repo: The remote URL or :class:`~docbuild.models.repo.Repo` instance of the repository to manage.
Repo instance of the repository.
:param permanent_root: The root directory for storing permanent bare clones.
:param gitconfig: The path to a separate Git configuration file
(=None, use the default config from etc/gitconfig)
"""
self._repo_model = Repo(remote_url)
if isinstance(repo, str):
self._repo_model = Repo(repo)
elif isinstance(repo, Repo):
self._repo_model = repo
else:
raise TypeError(
f"remote_url must be a string or Repo instance, got {type(repo)}"
)
self._permanent_root = rootdir
# The Repo model handles the "sluggification" of the URL
self.bare_repo_path = self._permanent_root / self._repo_model.slug
Expand Down
94 changes: 56 additions & 38 deletions tests/cli/cmd_repo/test_cmd_clone.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,32 @@
from docbuild.cli.cmd_repo.cmd_clone import clone
import docbuild.cli.cmd_repo.process as mod_process
from docbuild.cli.context import DocBuildContext
from docbuild.utils import shell as shell_module

log = logging.getLogger(__name__)


class _DummyPaths:
"""Lightweight stand-in for EnvPathsConfig used in tests.

Only the attributes accessed by cmd_repo.process are provided.
"""

def __init__(self, *, config_dir: str, repo_dir: str) -> None:
self.config_dir = config_dir
self.repo_dir = repo_dir


class _DummyEnv:
"""Minimal envconfig replacement exposing ``paths`` for tests.

This avoids constructing a full EnvConfig instance while still matching
the runtime behaviour expected by the cloning logic.
"""

def __init__(self, *, config_dir: str, repo_dir: str) -> None:
self.paths = _DummyPaths(config_dir=config_dir, repo_dir=repo_dir)

#
# @pytest.fixture
# def process_mock() -> AsyncMock:
Expand All @@ -29,8 +52,10 @@ def mock_subprocess(monkeypatch) -> AsyncMock:
process_mock.communicate.return_value = (b"stdout", b"stderr")
process_mock.returncode = 0
mock_create_subprocess = AsyncMock(return_value=process_mock)
# Git commands go through shell.run_command → asyncio.create_subprocess_exec
# in the shell utility module.
monkeypatch.setattr(
mod_process.asyncio, "create_subprocess_exec", mock_create_subprocess
shell_module.asyncio, "create_subprocess_exec", mock_create_subprocess
)
return mock_create_subprocess

Expand Down Expand Up @@ -63,9 +88,10 @@ def test_clone_from_xml_config(runner, tmp_path, mock_subprocess, caplog):
(config_dir / "sles.xml").write_text(xml_content)

context = DocBuildContext(
envconfig={
"paths": {"repo_dir": str(repo_dir), "config_dir": str(config_dir)},
},
envconfig=_DummyEnv(
config_dir=str(config_dir),
repo_dir=str(repo_dir),
),
)

runner.invoke(clone, [], obj=context)
Expand All @@ -78,35 +104,17 @@ def test_clone_from_xml_config(runner, tmp_path, mock_subprocess, caplog):
assert "https://github.com/test/two.git" in cloned_repos


def test_clone_invalid_envconfig(runner):
"""Test that an error is raised if the environment configuration is invalid."""
context = DocBuildContext(envconfig=None)

result = runner.invoke(
clone,
["org/repo"],
obj=context,
# catch_exceptions=True,
)

assert result.exit_code != 0
assert isinstance(result.exception, ValueError)
assert "No envconfig found in context" in str(result.exception)


# @pytest.mark.asyncio
async def test_process_stitchnode_none(monkeypatch, tmp_path):
"""Test that process raises ValueError if create_stitchfile returns None."""
# Patch create_stitchfile to return None
monkeypatch.setattr(mod_process, "create_stitchfile", AsyncMock(return_value=None))

context = DocBuildContext(
envconfig={
"paths": {
"repo_dir": str(tmp_path / "repos"),
"config_dir": str(tmp_path / "config"),
}
}
envconfig=_DummyEnv(
config_dir=str(tmp_path / "config"),
repo_dir=str(tmp_path / "repos"),
)
)

# The config_dir must exist, even if empty
Expand All @@ -119,18 +127,28 @@ async def test_process_stitchnode_none(monkeypatch, tmp_path):


async def test_process_configdir_none():
context = DocBuildContext(envconfig={"paths": {}})
with pytest.raises(
ValueError,
match=re.escape("Could not get a value from envconfig.paths.config_dir"),
):
await mod_process.process(context, repos=())
"""This scenario is no longer reachable with validated EnvConfig.

The higher-level CLI now ensures ``envconfig`` is a fully validated
environment configuration. Retain this test as a smoke check that
calling ``process`` with a dummy, but structurally valid, envconfig
does not raise and returns an integer exit code.
"""

context = DocBuildContext(
envconfig=_DummyEnv(config_dir="/non/existent/config", repo_dir="/tmp/repos"),
)

result = await mod_process.process(context, repos=())
assert isinstance(result, int)


async def test_process_repodir_none():
context = DocBuildContext(envconfig={"paths": {"config_dir": "/dummy/config"}})
with pytest.raises(
ValueError,
match=re.escape("Could not get a value from envconfig.paths.repo_dir"),
):
await mod_process.process(context, repos=())
"""See docstring of test_process_configdir_none for rationale."""

context = DocBuildContext(
envconfig=_DummyEnv(config_dir="/tmp/config", repo_dir="/non/existent/repos"),
)

result = await mod_process.process(context, repos=())
assert isinstance(result, int)
Loading
Loading