Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 3 additions & 3 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changes

## unreleased

- Drop 3.8 compat.
- Lint the codebase.


## [v1.6.0] - 2025-02-20

- Drop py3.7 compat.
Expand Down
5 changes: 4 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion src/cldfcatalog/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 18 additions & 12 deletions src/cldfcatalog/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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!')
Expand All @@ -45,17 +46,19 @@ 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)
return res

@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)

Expand All @@ -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)
Expand All @@ -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'):
Expand Down
22 changes: 11 additions & 11 deletions src/cldfcatalog/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,45 +26,45 @@ 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)
return res

# 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
38 changes: 21 additions & 17 deletions src/cldfcatalog/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,39 +13,42 @@

__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)
return cls(target)

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):
"""
Expand All @@ -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.
"""
Expand All @@ -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`.

Expand All @@ -95,36 +98,37 @@ 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.
"""
match = re.search(r'github\.com/(?P<org>[^/]+)/(?P<repo>[^.]+)', 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`
"""
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)

Expand All @@ -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.
Expand Down