Source code for gitws.workspace

# Copyright 2022-2023 c0fec0de
#
# This file is part of Git Workspace.
#
# Git Workspace is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Git Workspace is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with Git Workspace. If not, see <https://www.gnu.org/licenses/>.

"""
Workspace Handling.

The :any:`Workspace` class represents the location containing all git clones.
:any:`Info` is a helper.
"""
import logging
import shutil
from contextlib import contextmanager
from pathlib import Path
from typing import Generator, Optional

import tomlkit
from pydantic import Field

from ._basemodel import BaseModel
from ._util import resolve_relative
from .appconfig import AppConfig, AppConfigData, AppConfigLocation
from .const import GIT_WS_PATH, INFO_PATH, MANIFEST_PATH_DEFAULT
from .datamodel import GroupFilters, Project, WorkspaceFileRefs
from .exceptions import InitializedError, OutsideWorkspaceError, UninitializedError, WorkspaceNotEmptyError
from .workspacefinder import find_workspace

_LOGGER = logging.getLogger("git-ws")


[docs]class Info(BaseModel): """ Workspace Information Container. The workspace information container assembles all information which has to be kept persistent between tool invocations. Keyword Args: main_path: Path to main project. Relative to workspace root directory. """ main_path: Optional[Path] = None """ Path to main project. Relative to workspace root directory. """ filerefs: WorkspaceFileRefs = Field(default_factory=list) """ File References. These copied files and symbolic links have been created by GitWS and will be removed if not needed anymore. """
[docs] @staticmethod def load(path: Path) -> "Info": """ Load Workspace Information from GitWS root directory at ``path``. The workspace information is stored at ``{path}/.gitws/info.toml``. Args: path: Path to GitWS root directory. """ infopath = path / INFO_PATH doc = tomlkit.parse(infopath.read_text()) return Info( main_path=doc.get("main_path", None), filerefs=doc.get("filerefs", []), )
[docs] def save(self, path: Path): """ Save Workspace Information at GitWS root directory at ``path``. The workspace information is stored at ``{path}/.gitws/info.toml``. Args: path: Path to GitWS root directory. """ infopath = path / INFO_PATH infopath.parent.mkdir(parents=True, exist_ok=True) # structure try: doc = tomlkit.parse(infopath.read_text()) except FileNotFoundError: doc = tomlkit.document() doc.add(tomlkit.comment("Git Workspace System File. DO NOT EDIT.")) doc.add(tomlkit.nl()) # update selfdict = self.model_dump(exclude_none=True) selfdict["main_path"] = str(self.main_path) if self.main_path else None for name, value in selfdict.items(): if value: doc[name] = value else: doc.pop(name, None) # write infopath.write_text(tomlkit.dumps(doc))
[docs]class Workspace: """ Workspace. The workspace contains all git clones, but is *NOT* a git clone itself. A workspace refers to a main git clone or a standalone manifest, which defines the workspace content (i.e. dependencies). Args: path: Workspace Root Directory. info: Workspace Information. """ def __init__(self, path: Path, info: Info): self.path = path self.info = info self.app_config = AppConfig(workspace_config_dir=str(path / GIT_WS_PATH)) def __eq__(self, other): if isinstance(other, Workspace): return (self.path, self.info) == (other.path, other.info) return NotImplemented
[docs] @staticmethod def find_path(path: Optional[Path] = None) -> Path: """ Find Workspace Root Directory. Keyword Args: path (Path): directory or file within the workspace. Current working directory by default. Raises: UninitializedError: If directory of file is not within a workspace. The workspace root directory contains a sub directory ``.gitws``. This one is searched upwards the given ``path``. """ path = find_workspace(path=path) if path: return path raise UninitializedError()
[docs] @staticmethod def from_path(path=None) -> "Workspace": """ Create :any:`Workspace` for existing workspace at ``path``. Keyword Args: path (Path): directory or file within the workspace. Current working directory by default. Raises: UninitializedError: If directory of file is not within a workspace. The workspace root directory contains a sub directory ``.gitws``. This one is searched upwards the given ``path``. """ path = Workspace.find_path(path=path) info = Info.load(path) workspace = Workspace(path, info) _LOGGER.info("Workspace path=%s main=%s", path, info.main_path) _LOGGER.info("%r", workspace.config) return workspace
[docs] @staticmethod def is_init(path: Path) -> Optional[Info]: """Return :any:`Info` if workspace is already initialized.""" infopath = path / INFO_PATH if infopath.exists(): return Info.load(path) return None
[docs] @staticmethod def check_empty(path: Path, main_path: Optional[Path]): """Check if Workspace at ``path`` with ``main_path`` is empty.""" items = [item for item in path.iterdir() if item != main_path] if any(items): raise WorkspaceNotEmptyError(resolve_relative(path), items)
[docs] @staticmethod def init( path: Path, main_path: Optional[Path] = None, manifest_path: Optional[Path] = None, group_filters: Optional[GroupFilters] = None, depth: Optional[int] = None, force: bool = False, ) -> "Workspace": """ Initialize new :any:`Workspace` at ``path``. Args: path: Path to the workspace Keyword Args: main_path: Path to the main project. Relative to ``path``. manifest_path: Path to the manifest file. Relative to ``main_path`` or ``path``. Default is ``git-ws.toml``. group_filters: Group Filters. depth: Shallow Clone Depth. force: Ignore that the workspace exists. Raises: InitializedError: ``path`` already contains workspace. OutsideWorkspaceError: ``main_path`` is not within ``path``. """ if not force: info = Workspace.is_init(path) if info: raise InitializedError(path, info.main_path) # Normalize if main_path: try: main_path = (path / main_path).resolve().relative_to(path.resolve()) except ValueError: raise OutsideWorkspaceError(path, main_path, "Project") from None # Initialize Info info = Info(main_path=main_path) info.save(path) workspace = Workspace(path.resolve(), info) with workspace.app_config.edit(AppConfigLocation.WORKSPACE) as config: config.manifest_path = str(manifest_path or MANIFEST_PATH_DEFAULT) config.group_filters = group_filters config.depth = depth _LOGGER.info("Workspace path=%s main=%s", path, info.main_path) _LOGGER.info("%r", workspace.config) return workspace
[docs] def deinit(self): """ Deinitialize. Remove ``GIT_WS_PATH`` directory. """ shutil.rmtree(self.path / GIT_WS_PATH)
@property def main_path(self) -> Optional[Path]: """Resolved Path To Main Project.""" info_main_path = self.info.main_path if info_main_path: return self.path / info_main_path return None @property def base_path(self) -> Path: """Resolved Path To Main Project Or Workspace.""" info_main_path = self.info.main_path if info_main_path: return self.path / info_main_path return self.path @property def config(self) -> AppConfigData: """Application Configuration Values.""" return self.app_config.options
[docs] def get_project_path(self, project: Project, relative: bool = False) -> Path: """ Determine Project Path. Args: project: Project to determine path for. Keyword Args: relative: Return relative instead of absolute path. """ project_path = self.path / project.path if relative: project_path = resolve_relative(project_path) return project_path
[docs] def get_manifest_path(self, manifest_path: Optional[Path] = None) -> Path: """ Get Resolved Manifest Path. Keyword Args: manifest_path: Absolute Or Relative (To ``self.base_path``) Manifest Path. The manifest path is chosen according to the following list, the first matching wins: * Explicit manifest path specified by ``manifest_path``. * Path from configuration (set during ``init``, ``clone`` or later on). * ``git-ws.toml`` (default) """ return self.base_path / (manifest_path or self.app_config.options.manifest_path or MANIFEST_PATH_DEFAULT)
[docs] def get_group_filters(self, group_filters: Optional[GroupFilters] = None) -> GroupFilters: """ Get Group Filters. Keyword Args: group_filters: Group Filters. The group filter is chosen according to the following list, the first matching wins: * Explicit group filter specified by ``group_filters``. * Path from configuration (set during ``init``, ``clone`` or later on). * empty group filters. """ if group_filters is None: return self.app_config.options.group_filters or () return group_filters
[docs] @contextmanager def edit_info(self) -> Generator[Info, None, None]: """Yield Contextmanager to edit :any:`Info` and write back changes.""" try: yield self.info finally: self.info.save(self.path)