# 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/>.
"""
Central :any:`GitWS` Datamodel.
* :any:`Group`: Dependency Group. A string (i.e. 'test').
* :any:`GroupFilter`: Group Filter Specification. A string (i.e. '+test@path').
* :any:`GroupSelect`: Group Selection. A converted :any:`GroupFilter` as needed by :any:`GitWS`.
* :any:`ManifestSpec`: Manifest specification for the current project.
* :any:`Manifest`: Manifest as needed by :any:`GitWS` derived from :any:`ManifestSpec`.
* :any:`ProjectSpec`: Dependency Specification in :any:`ManifestSpec`.
* :any:`Project`: A Single Dependency as needed by :any:`GitWS` derived from :any:`ProjectSpec`.
* :any:`Remote`: Remote Alias in :any:`ManifestSpec`.
* :any:`Defaults`: Default Values in :any:`ManifestSpec`.
* :any:`AppConfigData`: :any:`GitWS` Configuration.
"""
import re
from pathlib import Path, PurePath
from typing import Any, Dict, List, Optional, Tuple
from pydantic import AfterValidator, ConfigDict, Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing_extensions import Annotated
from ._basemodel import BaseModel
from ._url import is_urlabs, urljoin, urlsub
from ._util import get_repr
from .const import MANIFEST_PATH_DEFAULT
from .exceptions import NoAbsUrlError
_RE_GROUP = re.compile(r"\A[a-zA-Z0-9_][a-zA-Z0-9_\-]*\Z")
ProjectPath = str
"""Project Path."""
ProjectPaths = Tuple[ProjectPath, ...]
def _validate_name(name):
"""
Validate Name.
name is just a ``str`` for performance reasons. This function does the validation.
"""
parts = PurePath(name).parts
if ".." in parts:
raise ValueError(f"Invalid name {name!r}")
return name
def _validate_path(path):
"""
Validate Path.
path is just a ``str`` for performance reasons. This function does the validation.
"""
parts = PurePath(path).parts
if ".." in parts:
raise ValueError(f"Invalid path {path!r}")
return path
def _validate_group(group):
"""
Validate Group.
Group is just a ``str`` for performance reasons. This function does the validation.
"""
mat = _RE_GROUP.match(group)
if not mat:
raise ValueError(f"Invalid group {group!r}")
return group
Group = Annotated[str, AfterValidator(_validate_group)]
"""
Dependency Group.
A group is a name consisting of lower- and uppercase letters, numbers and underscore.
Dashes are allowed. Except the first sign.
Groups structure dependencies.
"""
Groups = Tuple[Group, ...]
_RE_GROUP_FILTER = re.compile(r"\A(?P<select>[\-\+])(?P<group>[a-zA-Z0-9_][a-zA-Z0-9_\-]*)?(@(?P<path>.+))?\Z")
def _validate_group_filter(group_filter):
"""
Validate Group Filter.
Group Filter are just a ``tuple`` of ``str`` for performance reasons. This function does the validation.
"""
group_filter = str(group_filter)
mat = _RE_GROUP_FILTER.match(group_filter)
if not mat:
raise ValueError(f"Invalid group filter {group_filter!r}")
return group_filter
GroupFilter = Annotated[str, AfterValidator(_validate_group_filter)]
"""
Group Filter.
A group filter is a group name prefixed by '+' or '-', to select or deselect the group.
A group filter can have an optional path at the end.
Any :any:`GroupFilter` is later-on converted to a :any:`GroupSelect`.
"""
GroupFilters = Tuple[GroupFilter, ...]
"""
Groups Filter Specification from User.
Used by Config and Command Line Interface.
"""
[docs]class GroupSelect(BaseModel):
"""
Group Selection.
A group selection selects/deselects a specific group for a specific path.
Keyword Args:
select: Select (`True`) or Deselect (`False`)
group: Group Name.
path: Path.
"""
model_config = ConfigDict(frozen=True)
group: Optional[Group] = None
"""Group."""
select: bool
"""Selected ('+') or not ('-')."""
path: Optional[str] = None
"""Path."""
[docs] @staticmethod
def from_group(group: Group) -> "GroupSelect":
"""
Create :any:`GroupSelect` from ``group``.
>>> GroupSelect.from_group('test')
GroupSelect(group='test', select=True)
"""
return GroupSelect(group=group, select=True)
[docs] @staticmethod
def from_group_filter(group_filter: GroupFilter) -> "GroupSelect":
"""
Create Group Selection from ``group_filter``.
>>> GroupSelect.from_group_filter("+test")
GroupSelect(group='test', select=True)
>>> GroupSelect.from_group_filter("-test")
GroupSelect(group='test', select=False)
>>> GroupSelect.from_group_filter("-test@path")
GroupSelect(group='test', select=False, path='path')
>>> GroupSelect.from_group_filter("-@path")
GroupSelect(select=False, path='path')
>>> GroupSelect.from_group_filter("te-st")
Traceback (most recent call last):
...
ValueError: Invalid group selection 'te-st'
"""
mat = _RE_GROUP_FILTER.match(group_filter)
if not mat:
raise ValueError(f"Invalid group selection {group_filter!r}")
data = mat.groupdict()
data["select"] = data["select"] == "+"
return GroupSelect(**data)
def __str__(self):
select = "+" if self.select else "-"
path = f"@{self.path}" if self.path else ""
return f"{select}{self.group}{path}"
GroupSelects = Tuple[GroupSelect, ...]
"""Group Selects."""
[docs]def group_selects_from_groups(groups: Groups) -> GroupSelects:
"""Create :any:`GroupSelects` from `GroupFilters`."""
return tuple(GroupSelect.from_group(group) for group in groups)
[docs]def group_selects_from_filters(group_filters: GroupFilters) -> GroupSelects:
"""Create :any:`GroupSelects` from `GroupFilters`."""
return tuple(GroupSelect.from_group_filter(group_filter) for group_filter in group_filters)
[docs]class Remote(BaseModel):
"""
Remote Alias - Remote URL Helper.
Args:
name: Remote Name
Keyword Args:
url_base: Base URL. Optional.
"""
model_config = ConfigDict(frozen=True, populate_by_name=True)
name: str
"""The Name of the Remote. Must be unique within Manifest."""
url_base: str = Field(None, alias="url-base")
"""URL to a directory of repositories."""
[docs]class Defaults(BaseModel):
"""
Default Values.
These default values are used, if a :any:`ProjectSpec` does not specify them.
Keyword Args:
remote: Remote Name.
revision: Revision. Tag or Branch. SHA does not make sense here.
groups: Dependency Groups.
with_groups: Group Selection for referred projects.
"""
model_config = ConfigDict(frozen=True, populate_by_name=True, arbitrary_types_allowed=True)
remote: Optional[str] = None
"""Remote name if not specified by the dependency. The remote must have been defined previously."""
revision: Optional[str] = None
"""The revision if not specified by the dependency. Tag or Branch. SHA does not make sense here."""
groups: Optional[Groups] = ()
"""The ``groups`` attribute if not specified by the dependency."""
with_groups: Optional[Groups] = Field((), alias="with-groups")
"""The ``with_groups`` attribute if not specified by the dependency."""
submodules: bool = True
"""Initialize and Update `git submodules`. `True` by default."""
[docs]class FileRef(BaseModel):
"""
File Reference Specification.
The main project and first level dependencies might specify symbolic-links or files-to-copy.
Args:
src: Source - path relative to the project directory.
dest: Destination - relative path to the workspace directory.
"""
model_config = ConfigDict(frozen=True)
src: str
"""Source - path relative to the project directory."""
dest: str
"""Destination - relative path to the workspace directory."""
FileRefs = Tuple[FileRef, ...]
[docs]class MainFileRef(FileRef):
"""
Main Project File Reference.
Args:
src: Source - path relative to the project directory.
dest: Destination - relative path to the workspace directory.
Keyword Args:
groups: Groups
"""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
groups: Groups = ()
"""``groups`` specification."""
MainFileRefs = Tuple[MainFileRef, ...]
[docs]class WorkspaceFileRef(BaseModel):
"""
Current File Reference with Workspace.
Args:
project_path - Project Path.
src: Source - path relative to the project directory.
dest: Destination - relative path to the workspace directory.
Keyword Args:
hash_: Source File Hash for Copied Files.
"""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True, populate_by_name=True)
type_: str
"""File Type."""
project_path: str
"""Project Path."""
src: str
"""Source - path relative to `project_path`."""
dest: str
"""Destination - relative path to the workspace directory."""
hash_: Optional[int] = None
"""Hash - Source File Hash for Copied Files."""
WorkspaceFileRefs = List[WorkspaceFileRef]
[docs]class Project(BaseModel):
"""
Project.
A project describes a dependency.
Args:
name: Name.
path: Project Filesystem Path. Relative to Workspace Root Directory.
Keyword Args:
level: Dependency Tree Level.
url: URL. Assembled from ``remote`` s ``url_base``, ``sub_url`` and/or ``name``.
revision: Revision to be checked out. Tag, branch or SHA.
manifest_path: Path to the manifest file. Relative to ``path`` of project. ``git-ws.toml`` by default.
groups: Dependency Groups.
with_groups: Group Selection for referred project.
submodules: initialize and update `git submodules`
linkfiles: symbolic links to be created in the workspace
copyfiles: files to be created in the workspace
recursive: integrate dependencies of this dependency
is_main: Project is Main Project.
The :any:`ProjectSpec` represents the User Interface. The options which can be specified in the manifest file.
The :any:`Project` is the resolved version of :any:`ProjectSpec` with all calculated information needed by
:any:`GitWS` to operate.
:any:`Project.from_spec()` resolves a :any:`ProjectSpec` into a :any:`Project`.
:any:`ProjectSpec.from_project()` does the reverse.
.. note::
:any:`Project.from_spec()` resolves some attributes irreversible.
So ``Project.from_spec(ProjectSpec.from_project())`` will not
return the original project.
"""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True, populate_by_name=True)
name: Annotated[str, AfterValidator(_validate_name)]
"""Dependency Name."""
path: Annotated[str, AfterValidator(_validate_path)]
"""Dependency Path. ``name`` will be used as default."""
level: Optional[int] = None
"""Dependency Tree Level."""
url: Optional[str] = None
"""URL. Assembled from ``remote`` s ``url_base``, ``sub_url`` and/or ``name``."""
revision: Optional[str] = None
"""Revision to be checked out. Tag, branch or SHA."""
manifest_path: str = str(MANIFEST_PATH_DEFAULT)
"""Path to the manifest file. Relative to ``path`` of project. ``git-ws.toml`` by default."""
groups: Groups = ()
"""Dependency Groups."""
with_groups: Groups = Field((), alias="with-groups")
"""Group Selection for referred project."""
submodules: bool = True
"""Initialize and Update `git submodules`."""
linkfiles: FileRefs = ()
"""Symbolic Links To Be Created In The workspace."""
copyfiles: FileRefs = ()
"""Files To Be Created In The Workspace."""
recursive: bool = True
"""Integrate Dependencies of this dependency."""
is_main: bool = False
"""Project is the main project."""
@property
def info(self):
"""
`repr`-like info string but more condensed.
>>> Project(name='name', path='name').info
'name'
>>> Project(name='name', path='path').info
"name (path='path')"
>>> Project(name='name', path='name', revision='main').info
"name (revision='main')"
>>> Project(name='name', path='name', groups=('test', 'doc')).info
"name (groups='test,doc')"
"""
options = get_repr(
kwargs=(
("revision", self.revision, None),
("path", str(self.path), self.name),
("groups", ",".join(self.groups), ""),
("submodules", self.submodules, True),
)
)
if self.is_main:
options = f"MAIN {options}" if options else "MAIN"
if options:
return f"{self.name} ({options})"
return self.name
[docs] @staticmethod
def from_spec(
manifest_spec: "ManifestSpec",
spec: "ProjectSpec",
level: int,
refurl: Optional[str] = None,
resolve_url: bool = False,
) -> "Project":
"""
Create :any:`Project` from ``manifest_spec`` and ``spec``.
Args:
manifest_spec: Manifest Specification.
spec: Base project to be resolved.
level: Dependency tree level.
Keyword Args:
refurl: Remote URL of the ``manifest_spec``.
resolve_url: Resolve URLs to absolute ones.
Raises:
NoAbsUrlError: On ``resolve_url=True`` if ``refurl`` is ``None`` and project uses a relative URL.
:any:`Project.from_spec()` resolves a :any:`ProjectSpec` into a :any:`Project`.
:any:`ProjectSpec.from_project()` does the reverse.
"""
defaults = manifest_spec.defaults
remotes = manifest_spec.remotes
project_groups = spec.groups or defaults.groups
project_with_groups = spec.with_groups or defaults.with_groups
submodules = spec.submodules if spec.submodules is not None else defaults.submodules
# URL
url = spec.url
if not url:
# URL assembly
project_remote = spec.remote or defaults.remote
project_sub_url = spec.sub_url or urlsub(refurl, spec.name)
if project_remote:
for remote in remotes:
if remote.name == project_remote:
url = urljoin(remote.url_base, project_sub_url)
break
else:
raise ValueError(f"Unknown remote {spec.remote} for project {spec.name}")
else:
url = f"../{project_sub_url}"
# Resolve relative URLs.
if resolve_url and not is_urlabs(url):
if not refurl:
raise NoAbsUrlError(spec.name)
url = urljoin(refurl, url)
# Create
return Project(
name=spec.name,
level=level,
path=spec.path or spec.name,
url=url,
revision=spec.revision or defaults.revision,
manifest_path=spec.manifest_path,
groups=project_groups,
with_groups=project_with_groups,
submodules=submodules,
linkfiles=spec.linkfiles,
copyfiles=spec.copyfiles,
recursive=spec.recursive,
)
[docs]class ProjectSpec(BaseModel):
"""
Project Dependency Specification.
A project specifies the reference to a repository.
Args:
name: Name.
Keyword Args:
remote: Remote Alias - Remote URL Helper
sub_url: URL relative to :any:`Remote.url_base`.
url: URL
revision: Revision
path: Project Filesystem Path. Relative to Workspace Root Directory.
manifest_path: Path to the manifest file. Relative to ``path`` of project. ``git-ws.toml`` by default.
groups: Dependency Groups.
with_groups: Group Selection for referred project.
submodules: initialize and update `git submodules`
linkfiles: symbolic links to be created in the workspace
copyfiles: files to be created in the workspace
recursive: integrate dependencies of this dependency
Some parameters are restricted:
* ``remote`` and ``url`` are mutually exclusive.
* ``url`` and ``sub-url`` are likewise mutually exclusive
* ``sub-url`` requires a ``remote``.
The :any:`ProjectSpec` represents the User Interface. The options which can be specified in the manifest file.
The :any:`Project` is the resolved version of :any:`ProjectSpec` with all calculated information needed by
:any:`GitWS` to operate.
"""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True, populate_by_name=True)
name: Annotated[str, AfterValidator(_validate_name)]
"""Dependency Name."""
remote: Optional[str] = None
"""Remote Alias Name. The ``remote`` must have been defined previously."""
sub_url: Optional[str] = Field(None, alias="sub-url")
"""Relative URL to ``remote`` s ``url_base`` OR the URL of the manifest file."""
url: Optional[str] = None
"""Absolute URL."""
revision: Optional[str] = None
"""Revision to be checked out."""
path: Annotated[Optional[str], AfterValidator(_validate_path)] = None
"""Path within workspace. ``name`` will be used as default."""
manifest_path: Optional[str] = Field(str(MANIFEST_PATH_DEFAULT), alias="manifest-path")
"""Path to the manifest file. Relative to ``path`` of project. ``git-ws.toml`` by default."""
groups: Groups = ()
"""Dependency Groups."""
with_groups: Groups = Field((), alias="with-groups")
"""Group Selection for referred project."""
submodules: Optional[bool] = None
"""Initialize and Update `git submodules`."""
linkfiles: FileRefs = ()
"""Symbolic Links To Be Created In The Workspace."""
copyfiles: FileRefs = ()
"""Files To Be Created In The Workspace."""
recursive: bool = True
"""Integrate Dependencies of this dependency."""
@model_validator(mode="after")
def _remote_or_url(self):
remote = self.remote
sub_url = self.sub_url
url = self.url
if remote and url:
raise ValueError("'remote' and 'url' are mutually exclusive")
if url and sub_url:
raise ValueError("'url' and 'sub-url' are mutually exclusive")
if sub_url and not remote:
raise ValueError("'sub-url' requires 'remote'")
return self
[docs] @staticmethod
def from_project(project: Project) -> "ProjectSpec":
"""
Create :any:`ProjectSpec` from ``project``.
Args:
project: The source :any:`Project`.
.. note::
:any:`Project.from_spec()` resolves some attributes irreversible.
So ``Project.from_spec(ProjectSpec.from_project())`` will not
return the original project.
"""
return ProjectSpec(
name=project.name,
path=project.path,
url=project.url,
revision=project.revision,
manifest_path=project.manifest_path,
groups=project.groups,
with_groups=project.with_groups,
submodules=project.submodules,
linkfiles=project.linkfiles,
copyfiles=project.copyfiles,
recursive=project.recursive,
)
[docs]class Manifest(BaseModel):
"""
The Manifest.
A manifest describes the current project and its dependencies.
Keyword Args:
group_filters: Group Filtering.
linkfiles: symbolic links to be created in the workspace
copyfiles: files to be created in the workspace
dependencies: Dependency Projects.
path: Filesystem Path. Relative to Workspace Root Directory.
The :any:`ManifestSpec` represents the User Interface. The options which can be specified in the manifest file.
The :any:`Manifest` is the resolved version of :any:`ManifestSpec` with all calculated information needed by
:any:`GitWS` to operate.
:any:`Manifest.from_spec()` resolves a :any:`ManifestSpec` into a :any:`Manifest`.
"""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True, populate_by_name=True)
group_filters: GroupFilters = Field((), alias="group-filters")
"""Default Group Selection and Deselection."""
linkfiles: MainFileRefs = ()
"""Symbolic Links To Be Created In The Workspace."""
copyfiles: MainFileRefs = ()
"""Files To Be Created In The Workspace."""
dependencies: Tuple[Project, ...] = ()
"""Dependencies - Other Projects To Be Cloned In The Workspace."""
path: Optional[str] = None
"""Path to the manifest file, relative to project path."""
[docs] @staticmethod
def from_spec(
spec: "ManifestSpec", path: Optional[str] = None, refurl: Optional[str] = None, resolve_url: bool = False
) -> "Manifest":
"""
Create :any:`Manifest` from :any:`ManifestSpec`.
Args:
spec: The source :any:`ManifestSpec`.
Keyword Args:
path: File path of the ``spec``.
refurl: URL of the repository containing ``spec``.
resolve_url: Convert relative to absolute URLs. Requires ``refurl``.
Raises:
NoAbsUrlError: On ``resolve_url=True`` if ``refurl`` is ``None`` and project uses a relative URL.
"""
dependencies = [
Project.from_spec(spec, project_spec, 1, refurl=refurl, resolve_url=resolve_url)
for project_spec in spec.dependencies
]
return Manifest(
group_filters=spec.group_filters,
linkfiles=spec.linkfiles,
copyfiles=spec.copyfiles,
dependencies=dependencies,
path=path,
)
[docs]class ManifestSpec(BaseModel):
"""
ManifestSpec.
A manifest describes the current project and its dependencies.
The :any:`ManifestSpec` represents the User Interface. The options which can be specified in the manifest file.
The :any:`Manifest` is the resolved version of :any:`ManifestSpec` with all calculated information needed by
:any:`GitWS` to operate.
Keyword Args:
version: Version String. Currently 1.0.
remotes: Remote Aliases.
group_filters: Group Filtering.
linkfiles: symbolic links to be created in the workspace
copyfiles: files to be created in the workspace
defaults: Default settings.
dependencies: Dependency Projects.
"""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True, populate_by_name=True, extra="allow")
version: str = Field(default="1.0")
"""
Manifest Version Identifier.
Actual Version: ``1.0``.
"""
group_filters: GroupFilters = Field((), alias="group-filters")
"""Default Group Selection and Deselection."""
linkfiles: MainFileRefs = ()
"""Symbolic Links To Be Created In The Workspace."""
copyfiles: MainFileRefs = ()
"""Files To Be Created In The Workspace."""
remotes: Tuple[Remote, ...] = ()
"""Remotes - Helpers to Simplify URL Handling."""
defaults: Defaults = Defaults()
"""Default Values."""
dependencies: Tuple[ProjectSpec, ...] = ()
"""Dependencies - Other Projects To Be Cloned In The Workspace."""
@model_validator(mode="after")
def _validate_unique_remotes(self):
# unique remote names
names = set()
for remote in self.remotes:
name = remote.name
if name not in names:
names.add(name)
else:
raise ValueError(f"Remote name {name!r} is used more than once")
return self
@model_validator(mode="after")
def _validate_unique_deps(self):
# unique dependency names
names = set()
for dep in self.dependencies:
name = dep.name
if name not in names:
names.add(name)
else:
raise ValueError(f"Dependency name {name!r} is used more than once")
return self
[docs]class AppConfigData(BaseSettings):
"""
Configuration data of the application.
This class holds the concrete configuration values of the application.
The following values are defined:
"""
model_config = SettingsConfigDict(extra="allow")
manifest_path: Optional[str] = Field(
default=None, description="The path (relative to the project's root folder) to the manifest file."
)
"""
The path of the manifest file within a repository.
If this is not defined, the default is ``git-ws.toml``.
This option can be overridden by specifying the ``GIT_WS_MANIFEST_PATH`` environment variable.
"""
color_ui: Optional[bool] = Field(
default=None, description="If set to true, the output the tool generates will be colored."
)
"""
Defines if outputs by the tool shall be colored.
If this is not defined, output will be colored by default.
This option can be overridden by specifying the ``GIT_WS_COLOR_UI`` environment variable.
"""
group_filters: Optional[GroupFilters] = Field(default=None, description="The groups to operate on.")
"""
The groups to operate on.
This is a filter for groups to operate on during workspace actions.
This option can be overridden by specifying the ``GIT_WS_GROUP_FILTERS`` environment variable.
"""
clone_cache: Optional[Path] = Field(default=None, description="Local Cache for Git Clones.")
"""
Clone Cache.
Initial cloning all dependencies takes a while. This sums up if done often (i.e. in CI/CD).
This local filesystem cache directory will be used, to re-use already cloned data.
This option can be overridden by specifying the ``GIT_WS_CLONE_CACHE`` environment variable.
"""
depth: Optional[int] = Field(default=None, description="Default Clone Depth for New Clones")
"""
Default Clone Depth.
New clones are created with the given depth.
0 deactivates shallow cloning.
"""
[docs] @staticmethod
def defaults() -> Dict[str, Any]:
"""
As all configuration options must be optional, this option provides the default values.
>>> for item in AppConfigData.defaults().items(): print(item)
('color_ui', True)
('manifest_path', 'git-ws.toml')
"""
return {
"color_ui": True,
"manifest_path": str(MANIFEST_PATH_DEFAULT),
}