From c112a04d9e69a01359c72a54aac517a51efb12b3 Mon Sep 17 00:00:00 2001 From: Robert Forkel Date: Thu, 26 Feb 2026 21:40:28 +0100 Subject: [PATCH 01/11] removed deprecated stuff; started linting. --- .github/workflows/python-package.yml | 6 +- setup.cfg | 8 +- src/clldutils/apilib.py | 32 ----- src/clldutils/attrlib.py | 118 ---------------- src/clldutils/badge.py | 38 ----- src/clldutils/clilib.py | 140 +++---------------- src/clldutils/color.py | 24 ++-- src/clldutils/coordinates.py | 3 +- src/clldutils/markup.py | 202 ++++++++++++++++++--------- tests/test_apilib.py | 32 +---- tests/test_attrlib.py | 86 ------------ tests/test_badge.py | 13 -- tests/test_clilib.py | 80 +---------- tests/test_markup.py | 34 +++-- 14 files changed, 201 insertions(+), 615 deletions(-) delete mode 100644 src/clldutils/attrlib.py delete mode 100644 src/clldutils/badge.py delete mode 100644 tests/test_attrlib.py delete mode 100644 tests/test_badge.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 2bcce81..ba45b2c 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] + python-version: [3.9, '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/setup.cfg b/setup.cfg index 5b48503..1744365 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,12 +19,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 @@ -34,10 +34,10 @@ zip_safe = False packages = find: package_dir = = src -python_requires = >=3.8 +python_requires = >=3.9 install_requires = python-dateutil - tabulate>=0.7.7 + prettytable colorlog attrs>=18.1.0 bibtexparser>=2.0.0b4 @@ -100,7 +100,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 diff --git a/src/clldutils/apilib.py b/src/clldutils/apilib.py index 62636f8..a581680 100644 --- a/src/clldutils/apilib.py +++ b/src/clldutils/apilib.py @@ -7,11 +7,8 @@ import functools import webbrowser -import attr - from clldutils.misc import lazyproperty from clldutils.path import git_describe -from clldutils.attrlib import valid_range from clldutils.metadata import Metadata from clldutils.jsonlib import load @@ -19,21 +16,6 @@ r'v(?P(?P[0-9]+)\.(?P[0-9]+)(\.(?P[0-9]+))?)$') -# -# Common attributes of data objects -# -def latitude(): - return attr.ib( - converter=lambda s: None if s is None or s == '' else float(s), - validator=valid_range(-90, 90, nullable=True)) - - -def longitude(): - return attr.ib( - converter=lambda s: None if s is None or s == '' else float(s), - validator=valid_range(-180, 180, nullable=True)) - - def value_ascsv(v): if v is None: return '' @@ -46,20 +28,6 @@ def value_ascsv(v): return "{0}".format(v) -@attr.s -class DataObject(object): - - @classmethod - def fieldnames(cls): - return [f.name for f in attr.fields(cls)] - - def ascsv(self): - res = [] - for f, v in zip(attr.fields(self.__class__), attr.astuple(self)): - res.append((f.metadata.get('ascsv') or value_ascsv)(v)) - return res - - def assert_release(repos): match = VERSION_NUMBER_PATTERN.match(git_describe(repos)) assert match, 'Repository is not checked out to a valid release tag' diff --git a/src/clldutils/attrlib.py b/src/clldutils/attrlib.py deleted file mode 100644 index 232a656..0000000 --- a/src/clldutils/attrlib.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -Data curation can profit a lot from a transparent data model and documented structure. This can be -achieved using the `attrs` library, - -- defining core objects of the data as `@attr.s` decorated classes -- using `attrs` validation and conversion functionality, to observe the principle of locality - \ - i.e. have data cleanup defined close to the objects, while accessing clean data through the \ - objects elsewhere in the code base. -""" -import re -import functools -import collections - -import attr - -from clldutils.text import PATTERN_TYPE -from clldutils.misc import deprecated - -__all__ = ['asdict', 'valid_range', 'valid_re', 'valid_enum_member', 'cmp_off'] - -# Avoid deprecation warnings for "cmp=False" -# See https://www.attrs.org/en/stable/api.html#deprecated-apis -if getattr(attr, "__version_info__", (0,)) >= (19, 2): - cmp_off = {"eq": False} -else: # pragma: no cover - cmp_off = {"cmp": False} - - -def defaults(cls): - res = collections.OrderedDict() - for field in attr.fields(cls): - default = field.default - if isinstance(default, attr.Factory): - default = default.factory() - res[field.name] = default - return res - - -def asdict(obj, omit_defaults=True, omit_private=True): - """ - Enhanced version of `attr.asdict`. - - :param omit_defaults: If `True`, only attribute values which differ from the default will be \ - added. - :param omit_private: If `True`, values of private attributes (i.e. attributes with names \ - starting with `_`) will not be added. - - .. code-block:: python - - >>> @attr.s - ... class Bag: - ... _private = attr.ib() - ... with_default = attr.ib(default=7) - ... - >>> asdict(Bag('x')) - OrderedDict() - >>> asdict(Bag('x'), omit_defaults=False, omit_private=False) - OrderedDict([('_private', 'x'), ('with_default', 7)]) - >>> attr.asdict(Bag('x')) - {'_private': 'x', 'with_default': 7} - - """ - defs = defaults(obj.__class__) - res = collections.OrderedDict() - for field in attr.fields(obj.__class__): - if not (omit_private and field.name.startswith('_')): - value = getattr(obj, field.name) - if not (omit_defaults and value == defs[field.name]): - if hasattr(value, 'asdict'): - value = value.asdict(omit_defaults=True) - res[field.name] = value - return res - - -def _valid_enum_member(choices, instance, attribute, value, nullable=False): - if not (nullable and value is None) and value not in choices: - raise ValueError('{0} is not a valid {1}'.format(value, attribute.name)) - - -def valid_enum_member(choices, nullable=False): - """ - .. deprecated:: 3.9 - Use `attr.validators.in_` instead. - """ - deprecated('Use `attr.validators.in_` instead.') - return functools.partial(_valid_enum_member, choices, nullable=nullable) - - -def _valid_range(min_, max_, instance, attribute, value, nullable=False): - if not (nullable and value is None) and ( - (min_ is not None and value < min_) or (max_ is not None and value > max_)): - raise ValueError('{0} is not a valid {1}'.format(value, attribute.name)) - - -def valid_range(min_, max_, nullable=False): - """ - A validator that raises a `ValueError` if the provided value that is not in the range defined - by `min_` and `max_`. - """ - return functools.partial(_valid_range, min_, max_, nullable=nullable) - - -def _valid_re(regex, instance, attribute, value, nullable=False): - if nullable and value is None: - return - if not isinstance(regex, PATTERN_TYPE): - regex = re.compile(regex) - if not regex.match(value): - raise ValueError('{0} is not a valid {1}'.format(value, attribute.name)) - - -def valid_re(regex, nullable=False): - """ - .. deprecated:: 3.9 - Use `attr.validators.matches_re` instead. - """ - deprecated('Use `attr.validators.matches_re` instead.') - return functools.partial(_valid_re, regex, nullable=nullable) diff --git a/src/clldutils/badge.py b/src/clldutils/badge.py deleted file mode 100644 index d23f0e1..0000000 --- a/src/clldutils/badge.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Badges for inclusion in markdown docs, etc. - -.. seealso:: http://shields.io/ -""" -from urllib.parse import urlencode, quote - -__all__ = ['Colors', 'badge'] - - -class Colors(object): - """ - Colors available for shields.io badges. - """ - brightgreen = 'brightgreen' - green = 'green' - yellowgreen = 'yellowgreen' - yellow = 'yellow' - orange = 'orange' - red = 'red' - lightgrey = 'lightgrey' - blue = 'blue' - - -def badge(subject, status, color, fmt='svg', markdown=True, label=None, **kw) -> str: - """ - URL for (or markdown markup to include) a badge from shields.io - - :param str subject: Text for the left side of the badge - :param str status: Text for the right side of the badge - :param str color: Color for the right side of the badge - :param str fmt: `'svg'` or `'json'` - :param bool markdown: If `True`, return a markdown image link, else return a URL - :param str|None label: Link label, if `markdown==True` - """ - label = label or ': '.join([subject, status]) - url = 'https://img.shields.io/badge/{0}-{1}-{2}.{3}{4}'.format( - quote(subject), quote(status), color, fmt, '?' + urlencode(kw) if kw else '') - return '![{0}]({1} "{0}")'.format(label, url) if markdown else url diff --git a/src/clldutils/clilib.py b/src/clldutils/clilib.py index 7d202ea..c9a0200 100644 --- a/src/clldutils/clilib.py +++ b/src/clldutils/clilib.py @@ -68,7 +68,7 @@ def run(args): """ import csv import random -import typing +from typing import Optional, Any import logging import pkgutil import pathlib @@ -78,14 +78,11 @@ def run(args): import collections import importlib.metadata -import tabulate - -from clldutils.loglib import Logging, get_colorlog -from clldutils.misc import deprecated +from clldutils.loglib import get_colorlog from clldutils import markup __all__ = [ - 'ParserError', 'Command', 'command', 'ArgumentParser', 'ArgumentParserWithLogging', + 'ParserError', 'get_parser_and_subparsers', 'register_subcommands', 'PathType', 'add_format', 'Table', 'add_csv_field_size_limit', 'add_random_seed', 'confirm', ] @@ -97,104 +94,7 @@ def get_entrypoints(group): class ParserError(Exception): - pass - - -# Global registry for commands. -# Note: This registry is global so it can only be used for one ArgumentParser instance. -# Otherwise, different ArgumentParsers will share the same sub-commands which will rarely -# be intended. -_COMMANDS = [] - - -class Command(object): - def __init__(self, func, name=None, usage=None): - self.func = func - self.name = name or func.__name__ - self.usage = usage - - @property - def doc(self): - return self.usage or self.func.__doc__ - - def __call__(self, args): - return self.func(args) - - -def command(name=None, usage=None): - def wrap(f): - _COMMANDS.append(Command(f, name=name, usage=usage)) - return f - return wrap - - -def _attr(obj, attr): - return getattr(obj, attr, getattr(obj, '__{0}__'.format(attr), None)) - - -class ArgumentParser(argparse.ArgumentParser): - def __init_subclass__(cls, **kwargs): - if cls.__name__ != 'ArgumentParserWithLogging': - deprecated( - '{} inherits from clldutils.clilib.ArgumentParser which is deprecated.'.format( - cls.__name__ - )) - super().__init_subclass__(**kwargs) - - def __init__(self, pkg_name, *commands, **kw): - commands = commands or _COMMANDS - kw.setdefault( - 'description', "Main command line interface of the %s package." % pkg_name) - kw.setdefault( - 'epilog', "Use '%(prog)s help ' to get help about individual commands.") - super(ArgumentParser, self).__init__(**kw) - self.commands = collections.OrderedDict((_attr(cmd, 'name'), cmd) for cmd in commands) - self.pkg_name = pkg_name - self.add_argument("--verbosity", help="increase output verbosity") - self.add_argument('command', help=' | '.join(self.commands)) - self.add_argument('args', nargs=argparse.REMAINDER) - - def main(self, args=None, catch_all=False, parsed_args=None): - args = parsed_args or self.parse_args(args=args) - if args.command == 'help' and len(args.args): - # As help text for individual commands we simply re-use the docstrings of the - # callables registered for the command: - print(_attr(self.commands[args.args[0]], 'doc')) - else: - if args.command not in self.commands: - print('invalid command') - self.print_help() - return 64 - try: - self.commands[args.command](args) - except ParserError as e: - print(e) - print(_attr(self.commands[args.command], 'doc')) - return 64 - except Exception as e: - if catch_all: - print(e) - return 1 - raise - return 0 - - -class ArgumentParserWithLogging(ArgumentParser): - - def __init__(self, pkg_name, *commands, **kw): - super(ArgumentParserWithLogging, self).__init__(pkg_name, *commands, **kw) - self.add_argument('--log', default=get_colorlog(pkg_name), help=argparse.SUPPRESS) - self.add_argument( - '--log-level', - default=logging.INFO, - help='log level [ERROR|WARN|INFO|DEBUG]', - type=lambda x: getattr(logging, x)) - - def main(self, args=None, catch_all=False, parsed_args=None): - args = parsed_args or self.parse_args(args=args) - with Logging(args.log, level=args.log_level): - return super(ArgumentParserWithLogging, self).main( - catch_all=catch_all, parsed_args=args) + """Exception to signal errors during cli input validation.""" def confirm(question: str, default=True) -> bool: @@ -216,7 +116,7 @@ class Formatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionH def get_parser_and_subparsers(prog: str, with_defaults_help: bool = True, with_log: bool = True)\ - -> typing.Tuple[argparse.ArgumentParser, typing.Any]: + -> tuple[argparse.ArgumentParser, Any]: """ Get an `argparse.ArgumentParser` instance and associated subparsers. @@ -259,13 +159,13 @@ def iter_modules(pkg): try: yield name, importlib.import_module(modname) except Exception as e: # pragma: no cover - warnings.warn('{0} {1}'.format(e, modname)) + warnings.warn(f'{e} {modname}') def register_subcommands( subparsers, pkg: str, - entry_point: typing.Optional[str] = None, + entry_point: Optional[str] = None, formatter_class: argparse.ArgumentDefaultsHelpFormatter = Formatter, skip_invalid: bool = False): """ @@ -288,7 +188,7 @@ def register_subcommands( try: pkg = ep.load() except ImportError: - warnings.warn('ImportError loading entry point {0.name}'.format(ep)) + warnings.warn(f'ImportError loading entry point {ep.name}') continue _cmds.update( [('.'.join([ep.name, name]), mod) for name, mod in iter_modules(pkg)]) @@ -298,11 +198,11 @@ def register_subcommands( if not mod.__doc__: if skip_invalid: continue - raise ValueError('Command \"{0}\" is missing a docstring.'.format(name)) + raise ValueError(f'Command \"{name}\" is missing a docstring.') if not getattr(mod, 'run', None): # pragma: no cover if skip_invalid: continue - raise ValueError('Command \"{0}\" is missing a run function.'.format(name)) + raise ValueError(f'Command \"{name}\" is missing a run function.') valid[name] = mod subparser = subparsers.add_parser( @@ -339,7 +239,7 @@ def register(parser): ) -def add_random_seed(parser, default: typing.Optional[int] = None): +def add_random_seed(parser, default: Optional[int] = None): """ Command line tools may want to fix Python's `random.seed` to ensure reproducible results. @@ -362,8 +262,9 @@ def add_format(parser, default: str = 'pipe'): """ parser.add_argument( "--format", - default=default, - choices=tabulate.tabulate_formats, + default=markup.TableFormat.get(default), + type=markup.TableFormat.get, + choices=[e.name for e in markup.TableFormat], help="Format of tabular output.") @@ -393,7 +294,7 @@ def __init__(self, args: argparse.Namespace, *cols, **kw): super().__init__(*cols, **kw) -class PathType(object): +class PathType: # pylint: disable=R0903 """ A type to parse `pathlib.Path` instances from the command line. @@ -409,15 +310,18 @@ def register(parser): def run(args): assert args.input.exists() """ - def __init__(self, must_exist: bool = True, type: typing.Optional[str] = None): + def __init__( + self, + must_exist: bool = True, + type: Optional[str] = None): # pylint: disable=W0622 assert type in (None, 'dir', 'file') self._must_exist = must_exist self._type = type - def __call__(self, string): + def __call__(self, string: str): p = pathlib.Path(string) if self._must_exist and not p.exists(): - raise argparse.ArgumentTypeError('Path {0} does not exist!'.format(string)) + raise argparse.ArgumentTypeError(f'Path {string} does not exist!') if p.exists() and self._type and not getattr(p, 'is_' + self._type)(): - raise argparse.ArgumentTypeError('Path {0} is not a {1}!'.format(string, self._type)) + raise argparse.ArgumentTypeError(f'Path {string} is not a {self._type}!') return p diff --git a/src/clldutils/color.py b/src/clldutils/color.py index e3750da..87c686d 100644 --- a/src/clldutils/color.py +++ b/src/clldutils/color.py @@ -13,10 +13,11 @@ values - but will use different ways to create the scheme depending on the number of values. """ import math -import typing +from typing import Union, Optional import colorsys import fractions import itertools +from collections.abc import Sequence __all__ = [ 'diverging_colors', @@ -27,8 +28,10 @@ 'rgb_as_hex', ] +ColorType = Union[str, Sequence[float]] -def _to_rgb(s: typing.Union[str, list, tuple]) -> tuple: + +def _to_rgb(s: ColorType) -> tuple: def f2i(d): assert 0 <= d <= 1 res = int(math.floor(d * 256)) @@ -41,6 +44,7 @@ def f2i(d): if isinstance(s[0], (float, fractions.Fraction)): s = [f2i(d) for d in s] return s + assert isinstance(s, str) if s.startswith('#'): s = s[1:] @@ -50,24 +54,24 @@ def f2i(d): return tuple(int(c, 16) for c in [s[i:i + 2] for i in range(0, 6, 2)]) -def rgb_as_hex(s: typing.Union[str, list, tuple]) -> str: +def rgb_as_hex(s: ColorType) -> str: """ Convert a RGB triple to a `HEX triplet `_ """ - return '#{0:02X}{1:02X}{2:02X}'.format(*_to_rgb(s)) + return '#{0:02X}{1:02X}{2:02X}'.format(*_to_rgb(s)) # pylint: disable=C0209 -def brightness(color: typing.Union[str, list, tuple]) -> float: +def brightness(color: ColorType) -> float: """ Compute the brightness of a color specified as RGB triple (or Hex triplet). .. seealso:: ``_ """ - R, G, B = _to_rgb(color) + R, G, B = _to_rgb(color) # pylint: disable=invalid-name return 0.299 * R + 0.587 * G + 0.114 * B -def is_bright(color: typing.Union[str, list, tuple]) -> bool: +def is_bright(color: ColorType) -> bool: """ Compute whether a color is considered bright or not. @@ -79,7 +83,7 @@ def is_bright(color: typing.Union[str, list, tuple]) -> bool: return brightness(color) > 125 -def qualitative_colors(n: int, set: str = typing.Optional[str]) -> typing.List[str]: +def qualitative_colors(n: int, set: str = Optional[str]) -> list[str]: # pylint: disable=W0622 """ Choses `n` distinct colors suitable for visualizing categorical variables. @@ -204,7 +208,7 @@ def gethsvs(): itertools.islice((colorsys.hsv_to_rgb(*x) for x in gethsvs()), n)] -def sequential_colors(n): +def sequential_colors(n: int) -> list[str]: """ Between 3 and 9 sequential colors. @@ -226,7 +230,7 @@ def sequential_colors(n): return [cols[ix] for ix in indices[n - 3]] -def diverging_colors(n): +def diverging_colors(n: int) -> list[str]: """ Between 3 and 11 diverging colors diff --git a/src/clldutils/coordinates.py b/src/clldutils/coordinates.py index a02cf06..74abbb1 100644 --- a/src/clldutils/coordinates.py +++ b/src/clldutils/coordinates.py @@ -6,11 +6,10 @@ """ import re import math +import typing __all__ = ['Coordinates', 'dec2degminsec', 'degminsec2dec', 'degminsec'] -import typing - DEGREES = "°" MINUTES = "\u2032" SECONDS = "\u2033" diff --git a/src/clldutils/markup.py b/src/clldutils/markup.py index e541c34..ceb731a 100644 --- a/src/clldutils/markup.py +++ b/src/clldutils/markup.py @@ -1,12 +1,17 @@ +""" +Functionality for marking up text, mostly using Markdown. +""" import io import re import csv import sys -import typing +import enum +from typing import Union, Optional, Callable, Any, IO +import dataclasses import urllib.parse +from collections.abc import Generator, Sequence, Iterable -import attr -from tabulate import tabulate +from prettytable import PrettyTable, TableStyle from markdown import markdown from lxml import etree @@ -14,11 +19,30 @@ from clldutils.text import replace_pattern __all__ = [ - 'Table', + 'Table', 'TableFormat', 'iter_markdown_tables', 'iter_markdown_sections', 'add_markdown_text', 'MarkdownLink', 'MarkdownImageLink'] +class TableFormat(enum.Enum): + """Available formatting options for tables.""" + pipe = enum.auto() # pylint: disable=invalid-name + simple = enum.auto() # pylint: disable=invalid-name + tsv = enum.auto() # pylint: disable=invalid-name + csv = enum.auto() # pylint: disable=invalid-name + ascii = enum.auto() # pylint: disable=invalid-name + + @classmethod + def get(cls, s: Union[None, str, 'TableFormat']): + """Factory method, allowing selection of a format by name.""" + if s is None: + return cls.pipe + if isinstance(s, str): + return getattr(cls, s) + assert isinstance(s, cls) + return s + + class Table(list): """ A context manager to @@ -31,9 +55,9 @@ class Table(list): >>> with Table('col1', 'col2', tablefmt='simple') as t: ... t.append(['v1', 'v2']) ... - col1 col2 - ------ ------ - v1 v2 + col1 col2 + ------ ------ + v1 v2 For more control of the table rendering, a `Table` can be used without a `with` statement, calling :meth:`Table.render` instead: @@ -43,37 +67,55 @@ class Table(list): >>> t = Table('col1', 'col2') >>> t.extend([['z', 1], ['a', 2]]) >>> print(t.render(sortkey=lambda r: r[0], tablefmt='simple')) - col1 col2 - ------ ------ - a 2 - z 1 + col1 col2 + ------ ------ + a 2 + z 1 """ - def __init__(self, *cols: str, **kw): + def __init__( + self, + *cols: str, + rows: Optional[Sequence[Sequence[Any]]] = None, + file: Optional[IO] = None, + tablefmt: Optional[Union[str, TableFormat]] = None, + floatfmt: Optional[str] = '.2', + ): + """ + + """ self.columns = list(cols) - super(Table, self).__init__(kw.pop('rows', [])) - self._file = kw.pop('file', sys.stdout) - self._kw = kw - - def render(self, - sortkey=None, - condensed=True, - verbose=False, - reverse=False, - **kw): + super().__init__(rows or []) + self._file = file or sys.stdout + self._tablefmt = TableFormat.get(tablefmt) + self._floatfmt = floatfmt + + def render( # pylint: disable=R0913,R0917 + self, + sortkey: Callable[[Any], Any] = None, + condensed: bool = True, + verbose: bool = False, + reverse: bool = False, + tablefmt: Optional[Union[str, TableFormat]] = None, + floatfmt: Optional[str] = None, + ) -> str: """ :param sortkey: A callable which can be used as key when sorting the rows. :param condensed: Flag signalling whether whitespace padding should be collapsed. :param verbose: Flag signalling whether to output additional info. :param reverse: Flag signalling whether we should sort in reverse order. - :param kw: Additional keyword arguments are passed to the `tabulate` function. :return: String representation of the table in the chosen format. """ - tab_kw = dict(tablefmt='pipe', headers=self.columns, floatfmt='.2f') - tab_kw.update(self._kw) - tab_kw.update(kw) - if tab_kw['tablefmt'] == 'tsv': + if not self.columns and not self: + return '' + + tablefmt = self._tablefmt if tablefmt is None else TableFormat.get(tablefmt) + + if floatfmt is None: + floatfmt = self._floatfmt + + if tablefmt in (TableFormat.tsv, TableFormat.csv): res = io.StringIO() - w = csv.writer(res, delimiter='\t') + w = csv.writer(res, delimiter='\t' if tablefmt == TableFormat.tsv else ',') w.writerow(self.columns) for row in (sorted(self, key=sortkey, reverse=reverse) if sortkey else self): w.writerow(row) @@ -82,15 +124,30 @@ def render(self, if res.endswith('\r\n'): res = res[:-2] return res - res = tabulate( - sorted(self, key=sortkey, reverse=reverse) if sortkey else self, **tab_kw) - if tab_kw['tablefmt'] == 'pipe': + + table = PrettyTable() + table.field_names = self.columns + table.add_rows(sorted(self, key=sortkey, reverse=reverse) if sortkey else self) + + if tablefmt == TableFormat.pipe: + table.set_style(TableStyle.MARKDOWN) + elif tablefmt == TableFormat.simple: + table.border = False + table.preserve_internal_border = True + table.align = 'l' + table.vertical_char = ' ' + table.junction_char = ' ' + + table.float_format = floatfmt + res = str(table) + + if tablefmt == TableFormat.pipe: if condensed: # remove whitespace padding around column content: res = re.sub(r'\|[ ]+', '| ', res) res = re.sub(r'[ ]+\|', ' |', res) if verbose: - res += '\n\n(%s rows)\n\n' % len(self) + res += f'\n\n({len(self)} rows)\n\n' return res def __enter__(self): @@ -100,8 +157,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): print(self.render(), file=self._file) -def iter_markdown_tables(text) -> \ - typing.Generator[typing.Tuple[typing.List[str], typing.List[typing.List[str]]], None, None]: +def iter_markdown_tables(text: str) -> Generator[tuple[list[str], list[list[str]]], None, None]: """ Parse tables from a markdown formatted text. @@ -109,7 +165,7 @@ def iter_markdown_tables(text) -> \ :return: generator of (header, rows) pairs, where "header" is a `list` of column names and \ rows is a list of lists of row values. """ - def split_row(line, outer_pipes): + def split_row(line: str, outer_pipes: bool) -> list[str]: line = line.strip() if outer_pipes: assert line.startswith('|') and line.endswith('|'), 'inconsistent table formatting' @@ -120,11 +176,11 @@ def split_row(line, outer_pipes): yield split_row(header, outer_pipes), [split_row(row, outer_pipes) for row in rows] -def _iter_table_blocks(lines): +def _iter_table_blocks(lines: Iterable[str]) -> Generator[tuple[str, list[str], bool], None, None]: # Tables are detected by # 1. A header line, i.e. a line with at least one `|` # 2. A line separating header and body of the form below - SEP = re.compile(r'\s*\|?\s*:?--(-)+:?\s*(\|\s*:?--(-)+:?\s*)+\|?\s*') + sep = re.compile(r'\s*\|?\s*:?-(-)*:?\s*(\|\s*:?-(-)*:?\s*)+\|?\s*') lines = list(lines) header, table, outer_pipes = None, [], False @@ -135,17 +191,17 @@ def _iter_table_blocks(lines): yield header, table, outer_pipes header, table, outer_pipes = None, [], False else: - if not SEP.fullmatch(line): + if not sep.fullmatch(line): table.append(line) else: - if '|' in line and len(lines) > i + 1 and SEP.fullmatch(lines[i + 1]): + if '|' in line and len(lines) > i + 1 and sep.fullmatch(lines[i + 1]): header = line outer_pipes = lines[i + 1].strip().startswith('|') if table: yield header, table, outer_pipes -def iter_markdown_sections(text) -> typing.Generator[typing.Tuple[int, str, str], None, None]: +def iter_markdown_sections(text) -> Generator[tuple[int, str, str], None, None]: """ Parse sections from a markdown formatted text. @@ -170,9 +226,11 @@ def iter_markdown_sections(text) -> typing.Generator[typing.Tuple[int, str, str] yield level, header, ''.join(lines) -def add_markdown_text(text: str, - new: str, - section: typing.Optional[typing.Union[typing.Callable, str]] = None) -> str: +def add_markdown_text( + text: str, + new: str, + section: Optional[Union[Callable[[str], bool], str]] = None, +) -> str: """ Append markdown text to a (specific section of a) markdown document. @@ -187,7 +245,7 @@ def add_markdown_text(text: str, :raises ValueError: The specified section was not encountered. """ res = [] - for level, header, content in iter_markdown_sections(text): + for _, header, content in iter_markdown_sections(text): if header: res.append(header) res.append(content) @@ -206,7 +264,7 @@ def add_markdown_text(text: str, return res -@attr.s +@dataclasses.dataclass class MarkdownLink: """ Functionality to detect and manipulate links in markdown text. @@ -224,31 +282,35 @@ class MarkdownLink: >>> MarkdownLink.replace('[](http://example.com)', lambda ml: ml.update_url(scheme='https')) '[l](https://example.com)' """ - label = attr.ib() - url = attr.ib() - pattern = re.compile(r'(?[^]]*)]\((?P[^)]+)\)') - html_link = ('a', 'href') + label: str + url: str + pattern: re.Pattern = re.compile(r'(?[^]]*)]\((?P[^)]+)\)') + html_link: tuple[str, str] = ('a', 'href') @classmethod - def from_string(cls, s): + def from_string(cls, s) -> 'MarkdownLink': + """Create an instance from a Markdown formatted string, i.e. [...](...).""" try: return cls.from_match(cls.pattern.search(s)) - except AttributeError: - raise ValueError('No markdown link found') + except AttributeError as e: + raise ValueError('No markdown link found') from e @classmethod - def from_match(cls, match): + def from_match(cls, match) -> 'MarkdownLink': + """Create an instance from a match object as returned e.g. by .pattern.search.""" return cls(**match.groupdict()) @property - def parsed_url(self): + def parsed_url(self) -> urllib.parse.ParseResult: + """Parsed components of the link's HREF value.""" return urllib.parse.urlparse(self.url) @property - def parsed_url_query(self): + def parsed_url_query(self) -> dict[str, list[str]]: + """The query of the link's HREF value.""" return urllib.parse.parse_qs(self.parsed_url.query, keep_blank_values=True) - def update_url(self, **comps): + def update_url(self, **comps) -> 'MarkdownLink': """ Updates the `MarkdownLink.url` according to `comps`. @@ -267,14 +329,16 @@ def update_url(self, **comps): return self def __str__(self): - return '[{0.label}]({0.url})'.format(self) + return f'[{self.label}]({self.url})' @classmethod - def replace(cls, - md: str, - repl: typing.Callable, - simple: bool = True, - markdown_kw: typing.Optional[dict] = None) -> str: + def replace( + cls, + md: str, + repl: Callable[['MarkdownLink'], Any], + simple: bool = True, + markdown_kw: Optional[dict] = None, + ) -> str: """ Replace links in a markdown document. @@ -358,7 +422,7 @@ def replace(cls, [label](xyz) [label](url) - """ + """ links = [] if not simple: # We convert the markdown text to HTML and extract the links: @@ -367,9 +431,8 @@ def replace(cls, for node in tree.xpath('.//' + tag): links.append((slug(''.join(node.itertext())), node.get(attrib))) links = list(reversed(links)) - print(links) - def repl_wrapper(m): + def repl_wrapper(m: re.Match) -> Generator[str, None, None]: if not simple: if not links: # We got them all. @@ -391,10 +454,11 @@ def repl_wrapper(m): return replace_pattern(cls.pattern, repl_wrapper, md) -@attr.s +@dataclasses.dataclass class MarkdownImageLink(MarkdownLink): - pattern = re.compile(r'!\[(?P