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]
python-version: ["3.10", 3.11, 3.12, 3.13]

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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
The `pycldf` package adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).


## unreleased

- Remove dependency on `attrs`.

Note: Until versions of `pyglottolog` and `pyconcepticon` are released, which are compatible with
`clldutils` 4.x, catalog integration in `pycldf` is limited.


## [1.43.1] - 2026-03-25

Pin dependencies `csvw` and `clldutils`, since these will get incompatible new major versions.
Expand Down
4 changes: 4 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ Releasing pycldf
```shell
flake8 src
```
- Make sure pylint passes with a score of 10:
```shell
pylint src
```

- Make sure the docs render:
```shell
Expand Down
12 changes: 6 additions & 6 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 All @@ -35,10 +35,11 @@ zip_safe = False
packages = find:
package_dir =
= src
python_requires = >=3.8
python_requires = >=3.9
install_requires =
csvw<4
clldutils<4
csvw>=4.0
tabulate
clldutils>=4.0
uritemplate>=3.0
python-dateutil
simplepybtex
Expand Down Expand Up @@ -83,7 +84,6 @@ test =
pyconcepticon
pytest>=5
pytest-mock
requests-mock
pytest-cov
coverage>=4.2
docs =
Expand Down Expand Up @@ -117,7 +117,7 @@ show_missing = true
skip_covered = true

[tox:tox]
envlist = py3.8, py39, py310, py311, py312, py313
envlist = py39, py310, py311, py312, py313, py314
isolated_build = true
skip_missing_interpreter = true

Expand Down
5 changes: 5 additions & 0 deletions src/pycldf/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
"""
The `pycldf` package provides the reference implementation for the CLDF standard.

https://cldf.cldf.org
"""
from pycldf.dataset import *
from pycldf.db import *
from pycldf.sources import *
Expand Down
17 changes: 15 additions & 2 deletions src/pycldf/__main__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
"""
CLI for the `pycldf` pockage.
"""
import csv
import sys
from typing import Optional, Sequence
import logging
import contextlib

from clldutils.clilib import (
Expand All @@ -10,7 +15,15 @@
import pycldf.commands


def main(args=None, catch_all=False, parsed_args=None, log=None):
def main(
args: Sequence[str] = None,
catch_all: bool = False,
parsed_args: list = None,
log: Optional[logging.Logger] = None,
) -> Optional[int]:
"""
Implements the main command, dispatches to subcommands.
"""
parser, subparsers = get_parser_and_subparsers('cldf')
add_csv_field_size_limit(parser, default=csv.field_size_limit())
register_subcommands(subparsers, pycldf.commands)
Expand All @@ -32,7 +45,7 @@ def main(args=None, catch_all=False, parsed_args=None, log=None):
return 0
except ParserError as e:
print(colored(str(e), 'red'))
return main([args._command, '-h'])
return main([args._command, '-h']) # pylint: disable=protected-access
except Exception as e: # pragma: no cover
if catch_all:
print(e)
Expand Down
15 changes: 15 additions & 0 deletions src/pycldf/_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""
Backwards compatibility with supported python versions.
"""
import sys


if (sys.version_info.major, sys.version_info.minor) >= (3, 10): # pragma: no cover
def entry_points_select(eps, group):
"""
Staring with Python 3.10, `importlib.metadata.entry_points` returns `EntryPoints`."""
return eps.select(group=group)
else:
def entry_points_select(eps, group): # pragma: no cover
"""In Python 3.9, `importlib.metadata.entry_points` returns a `dict`."""
return eps.get(group, [])
50 changes: 33 additions & 17 deletions src/pycldf/cli_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,28 @@ def strtobool(val: str) -> int: # pragma: no cover
val = val.lower()
if val in ('y', 'yes', 't', 'true', 'on', '1'):
return 1
elif val in ('n', 'no', 'f', 'false', 'off', '0'):
if val in ('n', 'no', 'f', 'false', 'off', '0'):
return 0
else:
raise ValueError("invalid truth value %r" % (val,))
raise ValueError(f"invalid truth value {val}")


class FlagOrPathType(PathType):
class FlagOrPathType(PathType): # pylint: disable=too-few-public-methods
"""
Argument type allowing input of a path or a boolean.

The boolean can be used to determine whether to download a file from a known location.
"""
def __call__(self, string):
try:
return bool(strtobool(string))
except ValueError:
return super().__call__(string)


def http_head_status(url): # pragma: no cover
def http_head_status(url: str) -> int: # pragma: no cover
"""Do a HEAD request for `url` to determine its status."""
class NoRedirection(urllib.request.HTTPErrorProcessor):
"""Don't follow redirects."""
def http_response(self, request, response):
return response

Expand All @@ -56,22 +62,22 @@ def http_response(self, request, response):
return opener.open(urllib.request.Request(url, method="HEAD")).status


class UrlOrPathType(PathType):
def __call__(self, string):
class UrlOrPathType(PathType): # pylint: disable=too-few-public-methods
"""Type suitable for argparse arguments, allowing input of URL or local file path."""
def __call__(self, string: str) -> str:
if is_url(string):
if self._must_exist:
sc = http_head_status(string)
# We accept not only HTTP 200 as valid but also common redirection codes because
# these are used e.g. for DOIs.
if sc not in {200, 301, 302}:
raise argparse.ArgumentTypeError(
'URL {} does not exist [HTTP {}]!'.format(string, sc))
raise argparse.ArgumentTypeError(f'URL {string} does not exist [HTTP {sc}]!')
return string
super().__call__(string.partition('#')[0])
return string


def add_dataset(parser: argparse.ArgumentParser):
def add_dataset(parser: argparse.ArgumentParser) -> None:
"""
Adds a positional argument named `dataset` to the parser to specify a CLDF dataset.
"""
Expand Down Expand Up @@ -101,11 +107,17 @@ def get_dataset(args: argparse.Namespace) -> Dataset:
except TypeError as e: # pragma: no cover
if 'PathLike' in str(e):
raise ParserError(
'The dataset locator may require downloading, so you should specify --download-dir')
'The dataset locator may require downloading, so you should specify --download-dir'
) from e
raise


def add_database(parser, must_exist=True):
def add_database(parser: argparse.ArgumentParser, must_exist: bool = True) -> None:
"""
Add CLI arguments to specify a CLDF SQLite database.

Retrieve in the `run` function of a command using `get_database` (see below).
"""
add_dataset(parser)
parser.add_argument(
'db',
Expand All @@ -116,17 +128,21 @@ def add_database(parser, must_exist=True):
parser.add_argument('--infer-primary-keys', action='store_true', default=False)


def get_database(args):
def get_database(args: argparse.Namespace) -> Database:
"""
Retrieve a `Database` instance based on CLI input in `args` (see `add_database`).
"""
return Database(get_dataset(args), fname=args.db, infer_primary_keys=args.infer_primary_keys)


def add_catalog_spec(parser, name):
def add_catalog_spec(parser: argparse.ArgumentParser, name: str) -> None:
"""Add CLI arguments suitable to specify a catalog."""
parser.add_argument(
'--' + name,
metavar=name.upper(),
type=PathType(type='dir'),
help='Path to repository clone of {0} data'.format(name.capitalize()))
help=f'Path to repository clone of {name.capitalize()} data')
parser.add_argument(
'--{0}-version'.format(name),
help='Version of {0} data to checkout'.format(name.capitalize()),
f'--{name}-version',
help=f'Version of {name.capitalize()} data to checkout',
default=None)
6 changes: 3 additions & 3 deletions src/pycldf/commands/catmedia.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
from pycldf.media import MediaTable


def register(parser):
def register(parser): # pylint: disable=C0116
add_dataset(parser)


def run(args):
def run(args): # pylint: disable=C0116
ds = get_dataset(args)
res = MediaTable(ds).cat()
if res:
args.log.info('{} files have been recombined'.format(res))
args.log.info(f'{res} files have been recombined')
Loading