From 370363a13407d90d2dd2b0955783ed41f17c9018 Mon Sep 17 00:00:00 2001 From: Nick Williams Date: Tue, 20 Oct 2026 08:51:18 -0500 Subject: [PATCH] Refactor Config to extract DefaultConfig and RepositoryConfig subclasses This refactors `Config` to extract two new subclasses, `DefaultConfig` and `RepositoryConfig`, which are used to logically and semantically indicate the purpose of a given `Config` object. This also substantially improves the documentation for the existing `Config` class. --- docs/config.rst | 21 +- pygit2/__init__.py | 8 +- pygit2/config.py | 635 +++++++++++++++++++++++++++++++++++++------ pygit2/decl/config.h | 1 + pygit2/repository.py | 35 ++- test/test_config.py | 104 ++++++- test/utils.py | 2 +- 7 files changed, 681 insertions(+), 125 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 19258aad2..8035650c2 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -2,22 +2,31 @@ Configuration files ********************************************************************** -.. autoclass:: pygit2.Repository - :members: config - :noindex: +.. autoclass:: pygit2.repository.BaseRepository + :members: config, config_snapshot -The Config type +The Config types ================ .. autoclass:: pygit2.Config :members: :undoc-members: - :special-members: __contains__, __delitem__, __getitem__, __iter__, __setitem__ + :special-members: __contains__, __delitem__, __getitem__, __init__, __iter__, __setitem__ + +.. autoclass:: pygit2.DefaultConfig + :members: add_file, snapshot + :undoc-members: + :special-members: __init__ + +.. autoclass:: pygit2.RepositoryConfig + :members: add_file, snapshot + :undoc-members: + :special-members: __init__ The ConfigEntry type ==================== .. autoclass:: pygit2.config.ConfigEntry - :members: name, value, level + :members: level, name, raw_level, raw_name, raw_value, value, __eq__, __hash__ \ No newline at end of file diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 785ffe958..7afb1d6e4 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -301,7 +301,11 @@ git_fetch_options, git_proxy_options, ) -from .config import Config +from .config import ( + Config, + DefaultConfig, + RepositoryConfig, +) from .credentials import * from .errors import ( AlreadyExistsError, @@ -606,6 +610,7 @@ def filter_unregister(name: str) -> None: 'Branch', 'CertificateError', 'Commit', + 'DefaultConfig', 'Diff', 'DiffDelta', 'DiffFile', @@ -941,6 +946,7 @@ def filter_unregister(name: str) -> None: 'Remote', 'repository', 'Repository', + 'RepositoryConfig', 'branches', 'references', 'settings', diff --git a/pygit2/config.py b/pygit2/config.py index 321b4db1d..be613a183 100644 --- a/pygit2/config.py +++ b/pygit2/config.py @@ -22,10 +22,11 @@ # along with this program; see the file COPYING. If not, write to # the Free Software Foundation, 51 Franklin Street, Fifth Floor, # Boston, MA 02110-1301, USA. +from __future__ import annotations from collections.abc import Callable, Iterator from os import PathLike -from typing import TYPE_CHECKING, Any, Optional, Self +from typing import TYPE_CHECKING, Any, NoReturn, Self, overload try: from functools import cached_property @@ -39,7 +40,12 @@ from .utils import encode_fs_path, encode_string if TYPE_CHECKING: - from ._libgit2.ffi import GitConfigC, GitConfigEntryC, GitConfigIteratorC + from ._libgit2.ffi import ( + GitConfigC, + GitConfigEntryC, + GitConfigIteratorC, + GitRepositoryC, + ) from .repository import BaseRepository @@ -113,9 +119,9 @@ class Config: the constructor or by using one of the static methods :meth:`Config.get_system_config`, :meth:`Config.get_global_config`, or :meth:`Config.get_xdg_config`. Additional files can be loaded into the - `Config` object using :meth:`Config.add_file`. + ``Config`` object using :meth:`Config.add_file`. - Changes made to the configuration with :meth:`Config.set_multivar` are + Changes made to the configuration with one of the mutator methods are immediately persisted to disk. Reads performed with accessor methods like :meth:`Config.get_multivar` or :meth:`Config.__getitem__` may result in reading from different versions of the configuration file if this or @@ -125,53 +131,129 @@ class Config: is especially important when iterating, as the contents of the config might change mid-iteration if you don't use a snapshot. + Some ``Config`` objects may be backed by multiple files/backends, such as + if you call :meth:`Config.add_file` with different levels or construct + one of :class:`DefaultConfig` or :class:`RepositoryConfig`. In this case, + read operations begin at the backend with the highest + :class:`pygit2.enums.ConfigLevel` and proceed down one level at a time, + and write operations affect only the highest-level non-read-only backend. + This class can technically be used to manually read and write a repository's - configuration file by pointing the constructor to the repository's + local configuration by pointing the constructor to the repository's ``.git/config`` file, but this is not recommended. The resulting ``Config`` object represents only the configuration directly within ``.git/config``. It does not represent the total effective configuration for that repository - that includes the combined system, global (user), and global (user) XDG. - Instead, use :meth:`BaseRepository.config` for loading a repository's - configuration. + that includes the combined program, system, XDG, global (user), and local + configurations. Instead, use :meth:`pygit2.repository.BaseRepository.config` + or :meth:`pygit2.repository.BaseRepository.config_snapshot` for loading a + local configuration and see :class:`RepositoryConfig` for special behaviors + supported by the local configuration. + + For the non-repository global configuration used by Git during operations + not involving an existing repository (such as cloning), see + :class:`DefaultConfig`. """ - _repo: Optional['BaseRepository'] _config: 'GitConfigC' - def __init__(self, path: PathLike | str | None = None) -> None: - self._stored_exception: BaseException | None = None + @overload + def __init__(self, /) -> None: + """Constructs a new, empty ``Config`` object pointing to no file. - cconfig = ffi.new('git_config **') + To make changes to this ``Config`` object, you must use :meth:`Config.add_file`. + """ + ... - if not path: - err = C.git_config_new(cconfig) - else: - path_bytes = encode_fs_path(path) - err = C.git_config_open_ondisk(cconfig, path_bytes) + @overload + def __init__(self, path: PathLike | str, /) -> None: + """Constructs a ``Config`` object backed by the specified file. - check_error(err, io=True) - self._repo = None - self._config = cconfig[0] + The configuration from the specified file is loaded, and subsequent writes + will persist to that file. The file backend is assigned + :attr:`pygit2.enums.ConfigLevel.LOCAL`. Additional files can be added to the + config, with different levels, using :meth:`Config.add_file`. + + :param path: The path to the configuration file to load as a string or path-like + value. + :raises IOError: If the path specified does not exist or could not be opened. + """ + ... + + @overload + def __init__(self, *, c_config: 'GitConfigC', is_snapshot: bool) -> None: + """For internal use only. + + Constructs a ``Config`` object from a config object pointer. + """ + ... + + def __init__( + self, + path: PathLike | str | None = None, + *, + c_config: 'GitConfigC | None' = None, + is_snapshot: bool = False, + ) -> None: + """Constructs a ``Config`` object. + + **Overload 1: __init__()** + + Constructs a new, empty ``Config`` object pointing to no file. To make changes + to this ``Config`` object, you must use :meth:`Config.add_file`. + + **Overload 2: __init__(path)** + + The configuration from the specified file is loaded, and subsequent writes + will persist to that file. The file backend is assigned + :attr:`pygit2.enums.ConfigLevel.LOCAL`. Additional files can be added to the + config, with different levels, using :meth:`Config.add_file`. + + :param path: The path to the configuration file to load as a string or path-like + value. + :raises IOError: If the path specified does not exist or could not be opened. - @classmethod - def from_c(cls, repo: Optional['BaseRepository'], ptr: 'GitConfigC') -> 'Config': - config = cls.__new__(cls) - config._repo = repo - config._config = ptr + **Overload 3: __init__(c_config, is_snapshot)** - return config + For internal use only. + """ + # ^^ see https://github.com/sphinx-doc/sphinx/issues/7787 + if path is not None and c_config is not None: + raise ValueError('Cannot initialize Config from both path and c_config') + + self._is_snapshot = is_snapshot + self._stored_exception: BaseException | None = None + + if c_config is not None: + self._c_config = c_config + else: + c_config_ptr = ffi.new('git_config **') + + if not path: + err = C.git_config_new(c_config_ptr) + else: + path_bytes = encode_fs_path(path) + err = C.git_config_open_ondisk(c_config_ptr, path_bytes) + + check_error(err, io=True) + self._c_config = c_config_ptr[0] def __del__(self) -> None: try: - C.git_config_free(self._config) + C.git_config_free(self._c_config) except AttributeError: pass + def _check_error(self, err: int, io: bool = False): + try: + check_error(err, io=io, user_exception=self._stored_exception) + finally: + self._stored_exception = None + def _get(self, name: str | bytes) -> tuple[int, 'ConfigEntry | None']: name = str_to_bytes(name, 'name') c_entry_ptr = ffi.new('git_config_entry **') - err = C.git_config_get_entry(c_entry_ptr, self._config, name) + err = C.git_config_get_entry(c_entry_ptr, self._c_config, name) if err >= 0: try: @@ -181,109 +263,221 @@ def _get(self, name: str | bytes) -> tuple[int, 'ConfigEntry | None']: return err, None - def _get_entry(self, key: str | bytes) -> 'ConfigEntry': - err, entry = self._get(key) + def _get_entry(self, name: str | bytes) -> 'ConfigEntry': + self._stored_exception = None + err, entry = self._get(name) if err == C.GIT_ENOTFOUND: - raise KeyError(key) + raise KeyError(name) - check_error(err) + self._check_error(err) assert entry is not None return entry - def __contains__(self, key: str | bytes) -> bool: - err, cstr = self._get(key) + def __contains__(self, name: str | bytes) -> bool: + """Indicates whether the configuration contains ``name``. + + :param name: The name of the config var to look for. + :returns: ``True`` if any file/backend in this ``Config`` object contains any + config var with the name ``name``, ``False`` otherwise. + """ + self._stored_exception = None + err, _ = self._get(name) if err == C.GIT_ENOTFOUND: return False - check_error(err) + self._check_error(err) return True - def __getitem__(self, key: str | bytes) -> str | None: - """ - When using the mapping interface, the value is returned as a string. In - order to apply the git-config parsing rules, you can use - :meth:`Config.get_bool` or :meth:`Config.get_int`. + def __getitem__(self, name: str | bytes) -> str | None: + """Obtain the first value found for a config var with name ``name``. + + Looks for a config var with name ``name`` and returns its value as a string + or ``None`` if the config var exists but the value is empty. If you want to + apply the Git config parsing rules, use :meth:`Config.get_bool` or + :meth:`Config.get_int` instead of ``[name]``. + + If the config var found is a multivar, only the first value is returned. To + obtain all values, use :meth:`Config.get_multivar` instead of ``[name]``. + + If this ``Config`` is backed by multiple files/backends, the one with the highest + :class:`pygit2.enums.ConfigLevel` is searched first, and then the next-highest + level, and so on. + + :param name: The name of the config var to look for. + + :returns: The string value found for the config var with name ``name`` or, + in the case of a valueless flag, ``None``. + + :raises KeyError: If no config var with name ``name`` is found in any + of this ``Config``'s files/backends. """ - entry = self._get_entry(key) + entry = self._get_entry(name) return entry.value - def __setitem__(self, key: str | bytes, value: bool | int | str | bytes) -> None: - key = str_to_bytes(key, 'key') + def __setitem__(self, name: str | bytes, value: bool | int | str | bytes) -> None: + """Set the config var with name ``name`` to the specified ``value``. - err = 0 + If this ``Config`` is backed by multiple files/backends, only the first + non-read-only backend with the highest :class:`pygit2.enums.ConfigLevel` is + changed. If that backend is a file, changes are persisted to disk immediately. + + If the config var exists already, it is replaced. If it is already a multivar, + all values are removed in favor of this new value. If you want to add values + to a multivar instead of replacing them, use :meth:`Config.set_multivar` instead + of ``[name] = value``. + + :param name: The name of the config var to set. + :param value: The new value to set. + """ + self._stored_exception = None + name = str_to_bytes(name, 'name') + + err: int if isinstance(value, bool): - err = C.git_config_set_bool(self._config, key, value) + err = C.git_config_set_bool(self._c_config, name, value) elif isinstance(value, int): - err = C.git_config_set_int64(self._config, key, value) + err = C.git_config_set_int64(self._c_config, name, value) else: - err = C.git_config_set_string(self._config, key, encode_string(value)) + err = C.git_config_set_string(self._c_config, name, encode_string(value)) - check_error(err) + self._check_error(err) - def __delitem__(self, key: str | bytes) -> None: - key = str_to_bytes(key, 'key') + def __delitem__(self, name: str | bytes) -> None: + """Deletes the config var with name ``name``. - err = C.git_config_delete_entry(self._config, key) - check_error(err) + If this ``Config`` is backed by multiple files/backends, only the first + non-read-only backend with the highest :class:`pygit2.enums.ConfigLevel` is + changed. If that backend is a file, changes are persisted to disk immediately. - def __iter__(self) -> Iterator['ConfigEntry']: + If the config var is a multivar, all values are removed. If you want to remove + only some values, use :meth:`Config.delete_multivar` instead of ``del [name]``. + + :param name: Name of the config var to delete. + :raises KeyError: If no config var with name ``name`` is found in the first + non-read-only backend with the highest ``ConfigLevel``. """ - Iterate over configuration entries, returning a ``ConfigEntry`` - objects. These contain the name, level, and value of each configuration + self._stored_exception = None + name = str_to_bytes(name, 'name') + + err = C.git_config_delete_entry(self._c_config, name) + self._check_error(err) + + def __iter__(self) -> Iterator['ConfigEntry']: + """Iterate over configuration entries in the form of + :class:`pygit2.config.ConfigEntry` objects. + + Creates and returns an iterator over all of the entries in this ``Config`` + object. Entries contain the name, level, and value of each configuration variable. Be aware that this may return multiple versions of each entry if they are set multiple times in the configuration files. + + If this ``Config`` is backed by multiple files/backends, iteration begins with + all config vars from the backend with the highest + :class:`pygit2.enums.ConfigLevel` first, and then proceeds to the one with the + next-highest level, and so on. + + :returns: An iterator over :class:`pygit2.config.ConfigEntry` objects. """ - citer = ffi.new('git_config_iterator **') - err = C.git_config_iterator_new(citer, self._config) - check_error(err) + self._stored_exception = None + c_iter_ptr = ffi.new('git_config_iterator **') + err = C.git_config_iterator_new(c_iter_ptr, self._c_config) + self._check_error(err) - return ConfigIterator(self, citer[0]) + return ConfigIterator(self, c_iter_ptr[0]) def get_multivar( - self, name: str | bytes, regex: str | None = None + self, + name: str | bytes, + regex: str | None = None, ) -> Iterator[str | None]: - """Get each value of a multivar ''name'' as a list of strings. + """Get each value of a multivar ``name`` as an iterator of strings. + + If this ``Config`` is backed by multiple files/backends, iteration begins with + all matching multivars from the backend with the highest + :class:`pygit2.enums.ConfigLevel` first, and then proceeds to the one with the + next-highest level, and so on. + + :param name: The name of the multivar to iterate. + :param regex: If specified, only multivar values matching this regular expression + will be iterated. The regular expression is matched case-sensitively. + To iterate over all values, do not specify this argument, or use + the regular expression ``r'.*'``. + + :returns: An iterator of config var values, each of which will be either a + string or, in the case of a valueless flag, ``None``. - The optional ''regex'' parameter is expected to be a regular expression - to filter the variables we're interested in. + :raises KeyError: If no config var with name ``name`` is found in any of the + files/backends for this config. """ + self._stored_exception = None name = str_to_bytes(name, 'name') regex_bytes = encode_string(regex or None) - citer = ffi.new('git_config_iterator **') - err = C.git_config_multivar_iterator_new(citer, self._config, name, regex_bytes) - check_error(err) + c_iter_ptr = ffi.new('git_config_iterator **') + err = C.git_config_multivar_iterator_new( + c_iter_ptr, + self._c_config, + name, + regex_bytes, + ) + self._check_error(err) - return ConfigMultivarIterator(self, citer[0]) + return ConfigMultivarIterator(self, c_iter_ptr[0]) def set_multivar( - self, name: str | bytes, regex: str | bytes, value: str | bytes + self, + name: str | bytes, + regex: str | bytes, + value: str | bytes, ) -> None: - """Set a multivar ''name'' to ''value''. ''regexp'' is a regular - expression to indicate which values to replace. Changes are persisted - to the configuration file(s) backing this ``Config``. + """Add a ``value`` to multivar ``name``, optionally replacing other values + matching ``regex``. + + If this ``Config`` is backed by multiple files/backends, only the first + non-read-only backend with the highest :class:`pygit2.enums.ConfigLevel` is + changed. If that backend is a file, changes are persisted to disk immediately. + + :param name: The name of the multivar to set. + :param regex: A (required) regular expression to indicate which values to + replace. It is matched case-sensitively. Values that match are + removed, values that do not match are kept. To merely add + without removing any values, use the regular expression ``r'^$'``. + :param value: The value to add to the multivar. """ + self._stored_exception = None name = str_to_bytes(name, 'name') regex = str_to_bytes(regex, 'regex') value = str_to_bytes(value, 'value') - err = C.git_config_set_multivar(self._config, name, regex, value) - check_error(err) + err = C.git_config_set_multivar(self._c_config, name, regex, value) + self._check_error(err) def delete_multivar(self, name: str | bytes, regex: str | bytes) -> None: - """Delete a multivar ''name''. ''regexp'' is a regular expression to - indicate which values to delete. Changes are persisted to the - configuration file(s) backing this ``Config``. + """Delete values from a multivar with name ``name``. + + If this ``Config`` is backed by multiple files/backends, only the first + non-read-only backend with the highest :class:`pygit2.enums.ConfigLevel` is + changed. If that backend is a file, changes are persisted to disk immediately. + + :param name: The name of the multivar to delete. + :param regex: A (required) regular expression to indicate which values to + delete. It is matched case-sensitively. To delete all values of + the multivar, use the regular expression ``r'.*'`` or, + alternatively, ``del config[name]``. + + :raises KeyError: If no config var with name ``name`` is found in the first + non-read-only backend with the highest ``ConfigLevel``. """ + self._stored_exception = None name = str_to_bytes(name, 'name') regex = str_to_bytes(regex, 'regex') - err = C.git_config_delete_multivar(self._config, name, regex) - check_error(err) + err = C.git_config_delete_multivar(self._c_config, name, regex) + self._check_error(err) def get_bool(self, name: str | bytes) -> bool: """Obtain the last value found for a config var with name ``name`` as a boolean. @@ -310,6 +504,7 @@ def get_bool(self, name: str | bytes) -> bool: :raises ValueError: If the value does not match one of the expected truthy or falsy values. """ + self._stored_exception = None entry = self._get_entry(name) return self.parse_bool(entry.value) @@ -339,28 +534,83 @@ def get_int(self, name: bytes | str) -> int: :raises ValueError: If the value is ``None`` or empty or otherwise does not match the ``git_config_parse_int64`` parsing rules. """ + self._stored_exception = None entry = self._get_entry(name) return self.parse_int(entry.value) - def add_file(self, path: str | PathLike, level: int = 0, force: int = 0) -> None: - """Add a config file instance to an existing config.""" + def add_file( + self, + path: str | PathLike, + level: ConfigLevel | int | None = None, + force: bool | int = False, + ) -> None: + """Add a config file backend to this ``Config`` object. + + In a multi-backend ``Config`` object, write operations occur only against + the first non-read-only backend with the highest + :class:`pygit2.enums.ConfigLevel`, and read operations start with the backend + with the highest :class:`pygit2.enums.ConfigLevel` and continue down through + lower backends. Keep this in mind when choosing the value for ``level``. + + :param path: The path of the config file to add. + :param level: The config level for the new file backend. If not specified, the + special value "0" is used. This makes this new file the + lowest-priority backend of all, superseded by all other backends + in this config. + :param force: If another backend (of any kind) with the same level already + exists, that backend will be replaced if ``force`` is ``True`` or + 1, otherwise a ``ValueError`` will be raised. + + :raises ValueError: If another backend with the same level already exists and + ``force`` was not specified or was ``False`` or 0. + """ + self._stored_exception = None + if level is None: + level = 0 + elif isinstance(level, ConfigLevel): + level = level.value + + if force is True: + force = 1 + elif force is False: + force = 0 err = C.git_config_add_file_ondisk( - self._config, encode_fs_path(path), level, ffi.NULL, force + self._c_config, + encode_fs_path(path), + level, + ffi.NULL, + force, ) - check_error(err) + self._check_error(err) + + @property + def is_snapshot(self) -> bool: + """Indicates whether this Config object is a read-only snapshot + of the underlying configuration. + + :returns: ``True`` if it's a read-only snapshot, ``False`` otherwise. + """ + return self._is_snapshot - def snapshot(self) -> 'Config': - """Create a snapshot from this ``Config`` object. + def snapshot(self) -> Config: + """Create a read-only snapshot of this ``Config`` object. This means that looking up multiple values will use the same version of the configuration files. + + :raises TypeError: If this is already a snapshot. """ - ccfg = ffi.new('git_config **') - err = C.git_config_snapshot(ccfg, self._config) - check_error(err) + if self._is_snapshot: + raise TypeError('This config is already a snapshot.') + return Config(c_config=self._c_snapshot(), is_snapshot=True) - return Config.from_c(self._repo, ccfg[0]) + def _c_snapshot(self) -> 'GitConfigC': + self._stored_exception = None + c_config_ptr = ffi.new('git_config **') + err = C.git_config_snapshot(c_config_ptr, self._c_config) + self._check_error(err) + return c_config_ptr[0] # # Methods to parse a string according to the git-config rules @@ -452,6 +702,8 @@ def get_system_config() -> 'Config': The system configuration file is the one found at ``/etc/gitconfig`` or ``%PROGRAMFILES%\\Git\\etc\\gitconfig``, depending on the operating system. + + :raises IOError: If the configuration file is not found. """ return Config._from_found_config(C.git_config_find_system) @@ -463,6 +715,8 @@ def get_global_config() -> 'Config': location for Git, which is ``$HOME/.gitconfig``. This will not find the file at the XDG-compatible user config file location (for that, see :meth:`Config.get_xdg_config`). + + :raises IOError: If the configuration file is not found. """ return Config._from_found_config(C.git_config_find_global) @@ -473,10 +727,201 @@ def get_xdg_config() -> 'Config': The XDG-compatible user config file follows the XDG Base Directory Specification. This file is located at ``$HOME/.config/git/config``. This will not find the file at the standard user config location (for that, see :meth:`Config.get_global_config`). + + :raises IOError: If the configuration file is not found. """ return Config._from_found_config(C.git_config_find_xdg) +class DefaultConfig(Config): + """A special-case :class:`Config` extension representing the total default + configuration. + + This extension to the base ``Config`` class represents the total default configuration + outside the context of a repository (for that, see :class:`RepositoryConfig`). It also + serves as a semantic indicator of the scope of the configuration. + + The ``DefaultConfig`` includes the program, system, XDG, and global (user) + configurations. This is, in essence, the "effective" configuration that Git uses when + performing operations that are not against a repository. When a read operation occurs, + the configurations are searched in the following order: global (user), XDG, system, + and then program data. + """ + + @overload + def __init__(self, /) -> None: + """Load the total default configuration and construct a ``DefaultConfig`` + from it. + """ + ... + + @overload + def __init__(self, *, snapshot_of: DefaultConfig) -> None: + """For internal use only. + + Constructs a ``DefaultConfig`` from a given snapshot config object pointer. + The resulting configuration will be read-only. + """ + ... + + def __init__(self, *, snapshot_of: DefaultConfig | None = None) -> None: + """Constructs a ``DefaultConfig`` object. + + **Overload 1: __init__()** + + Loads the total default configuration. + + **Overload 2: __init__(c_snapshot)** + + For internal use only. + """ + if snapshot_of is not None: + super().__init__( + c_config=snapshot_of._c_snapshot(), + is_snapshot=True, + ) + else: + c_config_ptr = ffi.new('git_config **') + err = C.git_config_open_default(c_config_ptr) + check_error(err) + super().__init__(c_config=c_config_ptr[0], is_snapshot=False) + + def snapshot(self) -> DefaultConfig: + """Create a read-only snapshot of this ``DefaultConfig`` object. + + This means that looking up multiple values will use the same version + of the configuration files. + + :raises TypeError: If this is already a snapshot. + """ + if self._is_snapshot: + raise TypeError('This default config is already a snapshot.') + return DefaultConfig(snapshot_of=self) + + def add_file( + self, + path: str | PathLike, + level: ConfigLevel | int | None = None, + force: bool | int = False, + ) -> NoReturn: + """ + :raises TypeError: The default configuration does not support adding files. + """ + raise TypeError('The default configuration does not support adding files.') + + +class RepositoryConfig(Config): + """A special-case :class:`Config` extension that handles local (repository) + configuration. + + This extension to the base ``Config`` class handles some of the special behaviors + associated with repository configs, as well as serving as a semantic indicator for + the scope of the configuration. You should not construct it directly, but instead + use one of :meth:`pygit2.repository.BaseRepository.config` or + :meth:`pygit2.repository.BaseRepository.config_snapshot` to obtain the local + configuration. + + The ``RepositoryConfig`` represents not just the configuration present in + ``.git/config``, but the sum total of that plus the program, system, XDG, and global + (user) configurations. This is, in essence, the "effective" configuration that Git + uses when performing operations against this repository. When a read operation + occurs, the local configuration is searched first, then the global (user), XDG, + system, and finally program data configurations, in that order. When a write + operation occurs, only the local configuration is changed. + """ + + @overload + def __init__( + self, + repo: 'BaseRepository', + c_repo: 'GitRepositoryC', + *, + do_snapshot: bool = False, + ) -> None: + """For internal use only. + + See :meth:`pygit2.repository.BaseRepository.config` for obtaining a repository + configuration or :meth:`pygit2.repository.BaseRepository.config_snapshot` for + obtaining a snapshot. + + Constructs a new ``RepositoryConfig`` from the given repository. + + If ``do_snapshot`` is ``True`` (defaults to ``False``), a read-only snapshot + configuration will be created from the repository. Otherwise, a writeable + configuration will be created. + """ + ... + + @overload + def __init__( + self, + repo: 'BaseRepository', + c_repo: 'GitRepositoryC', + *, + snapshot_of: RepositoryConfig, + ) -> None: + """For internal use only. + + See :meth:`pygit2.repository.BaseRepository.config_snapshot` for obtaining a + repository configuration snapshot. + + Constructs a ``RepositoryConfig`` from a given snapshot config object pointer. + The resulting configuration will be read-only. + """ + ... + + def __init__( + self, + repo: 'BaseRepository', + c_repo: 'GitRepositoryC', + *, + do_snapshot: bool = False, + snapshot_of: RepositoryConfig | None = None, + ) -> None: + """Constructs a ``RepositoryConfig`` object. For internal use only.""" + self._repo = repo + self._c_repo = c_repo + if snapshot_of is not None: + super().__init__( + c_config=snapshot_of._c_snapshot(), + is_snapshot=True, + ) + else: + c_config_ptr = ffi.new('git_config **') + if do_snapshot: + err = C.git_repository_config_snapshot(c_config_ptr, self._c_repo) + else: + err = C.git_repository_config(c_config_ptr, self._c_repo) + check_error(err) + super().__init__(c_config=c_config_ptr[0], is_snapshot=do_snapshot) + + def snapshot(self) -> RepositoryConfig: + """Create a read-only snapshot of this ``RepositoryConfig`` object. + + This means that looking up multiple values will use the same version + of the configuration files. The resulting ``RepositoryConfig`` cannot be + used as a context manager, because it is read-only. + + :raises TypeError: If this is already a snapshot. + """ + if self._is_snapshot: + raise TypeError('This repository config is already a snapshot.') + return RepositoryConfig(self._repo, self._c_repo, snapshot_of=self) + + def add_file( + self, + path: str | PathLike, + level: ConfigLevel | int | None = None, + force: bool | int = False, + ) -> NoReturn: + """ + :raises TypeError: The local repository configuration does not support adding files. + """ + raise TypeError( + 'The local repository configuration does not support adding files.', + ) + + class ConfigEntry: """An entry in a configuration object.""" @@ -556,6 +1001,15 @@ def value(self) -> str | None: return self._raw_value.decode('utf-8') if self._raw_value is not None else None def __eq__(self, other: Any) -> bool: + """Tests whether this entry equals the other value. + + Two ``ConfigEntry`` objects are considered equal if they have the same values + for ``raw_level``, ``raw_name``, and ``raw_value``. + + If the other value is not a ``ConfigEntry``, this returns ``NotImplemented``. + + :returns: Whether ``other`` equals this entry. + """ if not isinstance(other, ConfigEntry): return NotImplemented return ( @@ -565,4 +1019,9 @@ def __eq__(self, other: Any) -> bool: ) def __hash__(self) -> int: + """Computes a hash for this entry. + + The hash is a combination of the hash of the ``raw_level``, ``raw_name``, + and ``raw_value``. + """ return hash((self._raw_level, self._raw_name, self._raw_value)) diff --git a/pygit2/decl/config.h b/pygit2/decl/config.h index 82003d739..f91865b58 100644 --- a/pygit2/decl/config.h +++ b/pygit2/decl/config.h @@ -49,6 +49,7 @@ int git_config_delete_multivar(git_config *cfg, const char *name, const char *re int git_config_new(git_config **out); int git_config_snapshot(git_config **out, git_config *config); int git_config_open_ondisk(git_config **out, const char *path); +int git_config_open_default(git_config **out); int git_config_find_system(git_buf *out); int git_config_find_global(git_buf *out); int git_config_find_xdg(git_buf *out); diff --git a/pygit2/repository.py b/pygit2/repository.py index 2c5637992..161706a76 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -56,7 +56,7 @@ git_checkout_options, git_stash_apply_options, ) -from .config import Config +from .config import RepositoryConfig from .enums import ( AttrCheck, BlameFlag, @@ -297,31 +297,28 @@ def __repr__(self) -> str: # Configuration # @property - def config(self) -> Config: - """The configuration file for this repository. + def config(self) -> RepositoryConfig: + """The configuration for this repository. - If the configuration hasn't been set yet, the default config for - repository will be returned, including global and system configurations - (if they are available). - """ - cconfig = ffi.new('git_config **') - err = C.git_repository_config(cconfig, self._repo) - check_error(err) + This includes any program, system, XDG, and global (user) configuration + present on the system, with the local repository config set to the highest + priority for reads and the only option for writes. - return Config.from_c(self, cconfig[0]) + If the configuration hasn't been set yet, the default config for the + repository will be created and returned. + """ + return RepositoryConfig(self, self._repo) @property - def config_snapshot(self): - """A snapshot for this repositiory's configuration + def config_snapshot(self) -> RepositoryConfig: + """A read-only snapshot of this repository's configuration. This allows reads over multiple values to use the same version - of the configuration files. + of the configuration files. As with :meth:`BaseRepository.config`, the + snapshot includes any program, system, XDG, or global (user) + present on the system. """ - cconfig = ffi.new('git_config **') - err = C.git_repository_config_snapshot(cconfig, self._repo) - check_error(err) - - return Config.from_c(self, cconfig[0]) + return RepositoryConfig(self, self._repo, do_snapshot=True) # # References diff --git a/test/test_config.py b/test/test_config.py index cb729adf9..4f4563350 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -28,7 +28,14 @@ import pytest -from pygit2 import Config, Repository, Settings +from pygit2 import ( + Config, + DefaultConfig, + GitError, + Repository, + RepositoryConfig, + Settings, +) from pygit2.config import ConfigEntry from . import utils @@ -40,7 +47,7 @@ def config_path(tmp_path: Path) -> Path: @pytest.fixture -def config(testrepo: Repository) -> Generator[Config, None, None]: +def config(testrepo: Repository) -> Generator[RepositoryConfig, None, None]: yield testrepo.config @@ -63,6 +70,12 @@ def test_system_config() -> None: pytest.skip(f'Unavailable for testing: {e}') +def test_default_config() -> None: + # this shouldn't throw, even if get_global_config and git_system_config don't find configs + config = DefaultConfig() + assert 'pygit2.test.default.config' not in config + + def test_new(config_path: Path) -> None: # Touch file config_path.touch() @@ -80,17 +93,35 @@ def test_new(config_path: Path) -> None: assert config_read['core.editor'] == 'ed' -def test_add(config_path: Path) -> None: +def test_add( + config_path: Path, + config: RepositoryConfig, +) -> None: with open(config_path, 'w') as new_file: new_file.write('[this]\n\tthat = true\n') new_file.write('[something "other"]\n\there = false') - config = Config() - config.add_file(config_path, 0) - assert 'this.that' in config - assert config.get_bool('this.that') - assert 'something.other.here' in config - assert not config.get_bool('something.other.here') + local_config = Config() + local_config.add_file(config_path, 0) + assert 'this.that' in local_config + assert local_config.get_bool('this.that') + assert 'something.other.here' in local_config + assert not local_config.get_bool('something.other.here') + + utils.assertRaisesWithArg( + TypeError, + 'The local repository configuration does not support adding files.', + config.add_file, + config_path, + ) + + default_config = DefaultConfig() + utils.assertRaisesWithArg( + TypeError, + 'The default configuration does not support adding files.', + default_config.add_file, + config_path, + ) def test_add_aspath(config_path: Path) -> None: @@ -291,7 +322,8 @@ def test_parsing() -> None: Config.parse_int('not a number') -def test_repository_config_snapshot(config: Config) -> None: +def test_repository_config_snapshot(config: RepositoryConfig) -> None: + assert not config.is_snapshot assert 'core.bare' in config assert not config.get_bool('core.bare') assert 'core.editor' in config @@ -300,12 +332,27 @@ def test_repository_config_snapshot(config: Config) -> None: assert config.get_int('core.repositoryformatversion') == 0 snapshot = config.snapshot() + assert not config.is_snapshot + assert snapshot.is_snapshot assert 'core.bare' in snapshot assert not snapshot.get_bool('core.bare') assert 'core.editor' in snapshot assert snapshot['core.editor'] == 'ed' assert 'core.repositoryformatversion' in snapshot assert snapshot.get_int('core.repositoryformatversion') == 0 + utils.assertRaisesWithArg( + GitError, + "cannot set 'something.other.changed': the configuration is read-only", + snapshot.set_multivar, + 'something.other.changed', + '^$', + 'foo', + ) + utils.assertRaisesWithArg( + TypeError, + 'This repository config is already a snapshot.', + snapshot.snapshot, + ) assert 'core.snapshot1' not in config assert 'core.snapshot1' not in snapshot @@ -326,16 +373,32 @@ def test_non_repository_config_snapshot(config_path: Path) -> None: new_file.write('[something "other"]\n\there = false') config = Config(config_path) + assert not config.is_snapshot assert 'this.that' in config assert config.get_bool('this.that') assert 'something.other.here' in config assert not config.get_bool('something.other.here') snapshot = config.snapshot() + assert not config.is_snapshot + assert snapshot.is_snapshot assert 'this.that' in snapshot assert snapshot.get_bool('this.that') assert 'something.other.here' in snapshot assert not snapshot.get_bool('something.other.here') + utils.assertRaisesWithArg( + GitError, + "cannot set 'something.other.changed': the configuration is read-only", + snapshot.set_multivar, + 'something.other.changed', + '^$', + 'foo', + ) + utils.assertRaisesWithArg( + TypeError, + 'This config is already a snapshot.', + snapshot.snapshot, + ) assert 'this.snapshot1' not in config assert 'this.snapshot1' not in snapshot @@ -348,3 +411,24 @@ def test_non_repository_config_snapshot(config_path: Path) -> None: 'this.snapshot1', lambda: snapshot.get_int('this.snapshot1'), ) + + +def test_default_config_snapshot() -> None: + config = DefaultConfig() + assert not config.is_snapshot + snapshot = config.snapshot() + assert not config.is_snapshot + assert snapshot.is_snapshot + utils.assertRaisesWithArg( + GitError, + "cannot set 'something.other.changed': the configuration is read-only", + snapshot.set_multivar, + 'something.other.changed', + '^$', + 'foo', + ) + utils.assertRaisesWithArg( + TypeError, + 'This default config is already a snapshot.', + snapshot.snapshot, + ) diff --git a/test/utils.py b/test/utils.py index 3b56eff55..32b45342b 100644 --- a/test/utils.py +++ b/test/utils.py @@ -151,7 +151,7 @@ def assertRaisesWithArg( ) -> None: with pytest.raises(exc_class) as excinfo: func(*args, **kwargs) - assert excinfo.value.args == (arg,) + assert excinfo.value.args == (arg,), f'{excinfo.value.args!r} != {(arg,)!r}' # Explicitly clear the Exception Info. Citing # https://docs.pytest.org/en/latest/reference.html#pytest-raises: