diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 65635ad..eaf5c85 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -12,12 +12,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.9, "3.10", 3.11, 3.12, 3.13] + python-version: ["3.10", 3.11, 3.12, 3.13, 3.14] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index 601b385..a970a16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changes +## unreleased + +- Drop 3.8 compat. +- Lint the codebase. + + ## [v1.6.0] - 2025-02-20 - Drop py3.7 compat. diff --git a/RELEASING.md b/RELEASING.md index 48465a5..b6c8989 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,7 +12,10 @@ Releasing cldfcatalog ```shell flake8 src ``` - +- Make sure pylint passes with a score of 10: + ```shell + pylint src + ``` - Update the version number, by removing the trailing `.dev0` in: - `setup.cfg` - `src/cldfcatalog/__init__.py` diff --git a/setup.cfg b/setup.cfg index 41bdfcc..34dc356 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,12 +20,12 @@ classifiers = Natural Language :: English Operating System :: OS Independent Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Programming Language :: Python :: 3.13 + Programming Language :: Python :: 3.14 Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: PyPy License :: OSI Approved :: Apache Software License @@ -82,7 +82,7 @@ show_missing = true skip_covered = true [tox:tox] -envlist = py38, py39, py310, py311, py312, py313 +envlist = py39, py310, py311, py312, py313, py314 isolated_build = true skip_missing_interpreter = true diff --git a/src/cldfcatalog/__init__.py b/src/cldfcatalog/__init__.py index f9b0193..c62b08e 100644 --- a/src/cldfcatalog/__init__.py +++ b/src/cldfcatalog/__init__.py @@ -1,4 +1,6 @@ -# +""" +A framework to implement CLDF reference catalogs. +""" from cldfcatalog.catalog import * # noqa: F401, F403 from cldfcatalog.repository import * # noqa: F401, F403 from cldfcatalog.config import * # noqa: F401, F403 diff --git a/src/cldfcatalog/catalog.py b/src/cldfcatalog/catalog.py index 906c49e..2ed0a66 100644 --- a/src/cldfcatalog/catalog.py +++ b/src/cldfcatalog/catalog.py @@ -7,8 +7,9 @@ """ import re import sys -import typing +from typing import Optional import pathlib +from collections.abc import Generator from cldfcatalog.repository import Repository from cldfcatalog.config import Config @@ -22,7 +23,7 @@ class Catalog(Repository): """ # If the catalog has a Python API, __api__ should point to the API class, which accepts # a repository directory as sole positional argument for initialization: - __api__ = None + __api__: Optional[type] = None # Catalogs are often used in command line applications. Thus, they need to refered to via # cli options or arguments. __cli_name__ can be used to specify a name for the catalog in these @@ -32,8 +33,8 @@ class Catalog(Repository): def __init__(self, path, tag: str = None, not_git_repo_ok: bool = True): if isinstance(self.__api__, str): raise ValueError( - 'API for catalog {0} is not available, please install {1}!'.format( - self.__class__.__name__, self.__api__)) + f'API for catalog {self.__class__.__name__} is not available, please install ' + f'{self.__api__}!') super().__init__(path, not_git_repo_ok=not_git_repo_ok) if not self.repo and tag: raise ValueError('A tag can only be specified for cloned repositories!') @@ -45,10 +46,11 @@ def __init__(self, path, tag: str = None, not_git_repo_ok: bool = True): @classmethod def default_location(cls) -> pathlib.Path: + """A default location for a catalog repository clone.""" return Config.dir().joinpath(cls.cli_name()) @classmethod - def clone(cls, url, target: typing.Optional[pathlib.Path] = None) -> 'Catalog': + def clone(cls, url, target: Optional[pathlib.Path] = None) -> 'Catalog': res = cls(Repository.clone(url, target or cls.default_location()).dir) with Config.from_file() as cfg: cfg.add_clone(res.cli_name(), res.dir) @@ -56,6 +58,7 @@ def clone(cls, url, target: typing.Optional[pathlib.Path] = None) -> 'Catalog': @classmethod def from_config(cls, key=None, fname=None, tag: str = None) -> 'Catalog': + """Initialize a catalog from config info.""" cfg = Config.from_file(fname) return cls(cfg.get_clone(key or cls.cli_name()), tag=tag) @@ -67,7 +70,7 @@ def __enter__(self): except TypeError: try: self._prev_head = self.repo.git.describe('--tags') - except Exception: # pragma: no cover + except Exception: # pragma: no cover # pylint: disable=W0718 pass # ... then checkout the requested state: self.checkout(self.tag) @@ -79,24 +82,27 @@ def __exit__(self, exc_type, exc_val, exc_tb): self._prev_head = None @classmethod - def cli_name(cls) -> str: + def cli_name(cls) -> str: # pylint: disable=C0116 return cls.__cli_name__ or cls.__name__.lower() @property - def api(self): + def api(self): # pylint: disable=C0116 if self.__api__ and self._api is None: - self._api = self.__api__(self.dir) + self._api = self.__api__(self.dir) # pylint: disable=E1102 return self._api @classmethod - def api_version(cls) -> typing.Union[str, None]: + def api_version(cls) -> Optional[str]: + """The version of the Python package providing the API for the repository.""" if cls.__api__: try: return sys.modules[cls.__api__.__module__.split('.')[0]].__version__ - except Exception: # pragma: no cover + except Exception: # pragma: no cover # pylint: disable=W0718 pass + return None # pragma: no cover - def iter_versions(self) -> typing.Generator[typing.List[str], None, None]: + def iter_versions(self) -> Generator[list[str], None, None]: + """Yield (tag, name) pairs for the repository.""" for line in reversed(self.repo.git.tag('-n').split('\n')): line = line.strip() if line.startswith('v'): diff --git a/src/cldfcatalog/config.py b/src/cldfcatalog/config.py index 66eb43f..ef08f0d 100644 --- a/src/cldfcatalog/config.py +++ b/src/cldfcatalog/config.py @@ -14,7 +14,7 @@ class Config(configparser.ConfigParser): """ - A config file for the cli. + A config file for the cli, usable as context manager. """ def __init__(self): self._files = None @@ -26,14 +26,14 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): self.to_file() - def read(self, filenames, encoding=None): + def read(self, filenames, encoding=None): # pylint: disable=C0116 if self._files: raise ValueError('Config cannot read more than one file!') self._files = filenames super().read(filenames, encoding=encoding) @staticmethod - def dir() -> pathlib.Path: + def dir() -> pathlib.Path: # pylint: disable=C0116 res = pathlib.Path(platformdirs.user_config_dir('cldf')) if not res.exists(): res.mkdir(parents=True, exist_ok=True) @@ -41,30 +41,30 @@ def dir() -> pathlib.Path: # Note: `fname` must not be defined at import, because we need to patch `appdirs` for tests! @staticmethod - def fname() -> pathlib.Path: + def fname() -> pathlib.Path: # pylint: disable=C0116 return Config.dir() / 'catalog.ini' @property - def clones(self) -> configparser.SectionProxy: + def clones(self) -> configparser.SectionProxy: # pylint: disable=C0116 if CLONES not in self.sections(): self[CLONES] = collections.OrderedDict() return self[CLONES] @classmethod - def from_file(cls, fname=None) -> 'Config': + def from_file(cls, fname=None) -> 'Config': # pylint: disable=C0116 cfg = cls() cfg.read(str(fname or cls.fname())) return cfg - def to_file(self, fname=None): + def to_file(self, fname=None): # pylint: disable=C0116 with (fname or self.fname()).open('w', encoding='utf8') as fp: self.write(fp) - def add_clone(self, key: str, path: typing.Union[str, pathlib.Path]): + def add_clone(self, key: str, path: typing.Union[str, pathlib.Path]): # pylint: disable=C0116 self.clones[key] = str(pathlib.Path(path).resolve()) - def get_clone(self, key: str) -> pathlib.Path: + def get_clone(self, key: str) -> pathlib.Path: # pylint: disable=C0116 try: return pathlib.Path(self.clones[key]) - except KeyError: - raise KeyError('Config {0} has no entry for {1}'.format(self._files, key)) + except KeyError as e: + raise KeyError(f'Config {self._files} has no entry for {key}') from e diff --git a/src/cldfcatalog/repository.py b/src/cldfcatalog/repository.py index 2251649..42fe0b6 100644 --- a/src/cldfcatalog/repository.py +++ b/src/cldfcatalog/repository.py @@ -3,7 +3,7 @@ functionality, following the [Facade pattern](https://en.wikipedia.org/wiki/Facade_pattern). """ import re -import typing +from typing import Union, Optional import pathlib import git @@ -13,31 +13,34 @@ __all__ = ['Repository', 'get_test_repo'] +PathType = Union[str, pathlib.Path] + class Repository: """ A (clone of a) git repository (or simply a directory). """ - def __init__(self, path: typing.Union[str, pathlib.Path], not_git_repo_ok: bool = False): + def __init__(self, path: PathType, not_git_repo_ok: bool = False): """ :param non_git_repo_ok: If `True`, a plain directory will work as `path`, too. But the \ `Repository` instance will have limited functionality. """ path = pathlib.Path(path) if path is not None else path if path is None or not path.exists(): - raise ValueError('invalid repository path: {0}'.format(path)) + raise ValueError(f'invalid repository path: {path}') self._dir = None try: - self.repo = git.Repo(str(path)) - except (git.exc.NoSuchPathError, git.exc.InvalidGitRepositoryError): + self.repo: Optional[git.Repo] = git.Repo(str(path)) + except (git.exc.NoSuchPathError, git.exc.InvalidGitRepositoryError) as e: if not not_git_repo_ok: - raise ValueError('invalid git repository: {0}'.format(path)) + raise ValueError(f'invalid git repository: {path}') from e self.repo = None self._dir = path self._url = None @classmethod - def clone(cls, url: str, target: typing.Union[str, pathlib.Path]): + def clone(cls, url: str, target: PathType) -> 'Repository': + """Clone the repository identified by url to target.""" target = pathlib.Path(target) assert (not target.exists()) and target.parent.exists() and target.parent.is_dir() git.Git(str(target.parent)).clone(url, target.name) @@ -45,7 +48,7 @@ def clone(cls, url: str, target: typing.Union[str, pathlib.Path]): def _require_repo(self, attr): if not self.repo: - raise ValueError('{} is not supported for repository exports'.format(attr)) + raise ValueError(f'{attr} is not supported for repository exports') def update(self): """ @@ -64,7 +67,7 @@ def dir(self) -> pathlib.Path: return pathlib.Path(self.repo.working_dir) if self.repo else self._dir @property - def active_branch(self) -> typing.Union[None, str]: + def active_branch(self) -> Optional[str]: """ :return: Name of the active branch or `None`, if in "detached HEAD" state. """ @@ -75,7 +78,7 @@ def active_branch(self) -> typing.Union[None, str]: return None @property - def url(self) -> typing.Union[str, None]: + def url(self) -> Optional[str]: """ :return: The URL of the remote called `origin` - if it is set, else `None`. @@ -95,7 +98,7 @@ def url(self) -> typing.Union[str, None]: return self._url @property - def github_repo(self) -> typing.Union[None, str]: + def github_repo(self) -> Optional[str]: """ :return: GitHub repository name in the form "ORG/REPO", or `None`, if no matching \ `self.url` is found. @@ -103,9 +106,10 @@ def github_repo(self) -> typing.Union[None, str]: match = re.search(r'github\.com/(?P[^/]+)/(?P[^.]+)', self.url or '') if match: return match.group('org') + '/' + match.group('repo') + return None # pragma: no cover @property - def tags(self) -> typing.List[str]: + def tags(self) -> list[str]: """ :return: `list` of tags available for the repository. A tag can be used as `spec` argument \ for `Repository.checkout` @@ -113,18 +117,18 @@ def tags(self) -> typing.List[str]: self._require_repo('tags') return self.repo.git.tag().split() - def describe(self) -> str: + def describe(self) -> str: # pylint: disable=C0116 self._require_repo('describe') return self.repo.git.describe('--always', '--tags') - def hash(self) -> str: + def hash(self) -> str: # pylint: disable=C0116 return self.describe().split('-g')[-1] - def is_dirty(self): + def is_dirty(self): # pylint: disable=C0116 self._require_repo('is_dirty') return self.repo.is_dirty() - def checkout(self, spec: str): + def checkout(self, spec: str): # pylint: disable=C0116 self._require_repo('checkout') return self.repo.git.checkout(spec) @@ -141,7 +145,7 @@ def json_ld(self, **dc) -> dict: ).json_ld() -def get_test_repo(directory, remote_url=None, tags=None, branches=None): +def get_test_repo(directory: PathType, remote_url=None, tags=None, branches=None): """ Since mocking a git repo is somewhat difficult, we provide this function to create a "real" git repository for testing.