From a1afc43aaafbbd6f98ee1a54b34951f4f603cad6 Mon Sep 17 00:00:00 2001 From: robot-piglet Date: Wed, 8 Jul 2026 12:44:10 +0300 Subject: Intermediate changes commit_hash:b287a7802acecdc3d13442505be5815167cf4f71 --- .../cryptography/next/rust/.yandex_meta/build.ym | 75 ++-------------------- contrib/python/cryptography/next/rust/msan.cpp | 23 ------- .../python/cryptography/next/rust/patches/msan.cpp | 23 ------- contrib/python/cryptography/next/rust/ya.make | 7 +- contrib/python/deepmerge/py3/.dist-info/METADATA | 23 ++++--- contrib/python/deepmerge/py3/deepmerge/_version.py | 30 +++++---- .../python/deepmerge/py3/deepmerge/extended_set.py | 19 ++++-- .../deepmerge/py3/deepmerge/strategy/__init__.py | 11 ++++ .../deepmerge/py3/deepmerge/strategy/fallback.py | 1 - .../py3/deepmerge/tests/strategy/test_list.py | 20 ++++++ .../deepmerge/py3/deepmerge/tests/test_full.py | 37 +++++++++++ contrib/python/deepmerge/py3/ya.make | 2 +- contrib/python/hypothesis/py3/.dist-info/METADATA | 2 +- contrib/python/hypothesis/py3/hypothesis/core.py | 14 +++- .../python/hypothesis/py3/hypothesis/version.py | 2 +- contrib/python/hypothesis/py3/ya.make | 2 +- contrib/python/scramp/.dist-info/METADATA | 11 +++- contrib/python/scramp/README.md | 9 +++ contrib/python/scramp/scramp/__init__.py | 2 +- contrib/python/scramp/scramp/core.py | 71 ++++++++++---------- contrib/python/scramp/scramp/exceptions.py | 8 +++ contrib/python/scramp/scramp/utils.py | 8 ++- contrib/python/scramp/ya.make | 3 +- 23 files changed, 208 insertions(+), 195 deletions(-) delete mode 100644 contrib/python/cryptography/next/rust/msan.cpp delete mode 100644 contrib/python/cryptography/next/rust/patches/msan.cpp create mode 100644 contrib/python/scramp/scramp/exceptions.py (limited to 'contrib/python') diff --git a/contrib/python/cryptography/next/rust/.yandex_meta/build.ym b/contrib/python/cryptography/next/rust/.yandex_meta/build.ym index 1a9f1137767..9500b46cce9 100644 --- a/contrib/python/cryptography/next/rust/.yandex_meta/build.ym +++ b/contrib/python/cryptography/next/rust/.yandex_meta/build.ym @@ -30,6 +30,10 @@ _openssl {% block current_version %}41.0.6{% endblock %} {% block current_url %}https://github.com/pyca/cryptography/archive/refs/tags/{{self.version().strip()}}.tar.gz{% endblock %} +{% block msan_cc_extra_flags %} +-I{{arcadia_root}} -I{{arcadia_root}}/contrib/libs/python/Include -I{{arcadia_root}}/contrib/libs/openssl/include +{% endblock %} + {% block patch_source %} {{super()}} sed -e 's|cdylib|staticlib|' -i Cargo.toml @@ -52,33 +56,6 @@ export RUSTFLAGS="${RUSTFLAGS:-} -Cembed-bitcode=no" rm -rf docs vectors tests cd src/rust {{super()}} -find ${BUILD} -name '*.a' -type f | while read l; do - echo "_rust_eh_personality {{self.mod_name().strip()}}__rust_eh_personality" >> prog - llvm-objcopy --redefine-syms=prog ${l} - rm prog -done - -{% for p in self.msan_platforms().strip().split('\n') %} -export CC_{{p.replace('-', '_')}}=target-cc-msan -{% endfor %} -export RUSTFLAGS="${RUSTFLAGS:-} -Zsanitizer=memory -Zsanitizer-memory-track-origins=2 -Ctarget-feature=-crt-static -Cunsafe-allow-abi-mismatch=sanitizer -Ccodegen-units=1" - -{% for arch in self.msan_platforms().strip().split('\n') %} -LD_PRELOAD=${BIN}/getrandom.so cargo {{self.cargo_flags().strip()}} build {{self.cargo_build_flags().strip()}} --release --target {{arch}} {{self.binary_type().strip()}} --target-dir ${BUILD}-msan \ - {% if self.cargo_target() %} ---package {{self.cargo_target().strip()}} - {% endif %} -{% endfor %} - -find ${BUILD}-msan -name '*.a' -type f | while read l; do - llvm-nm --extern-only --defined-only -A ${l} | sed -e 's|.* ||' | grep -v 'PyInit_' | sort | while read s; do - echo "${s} {{self.mod_name().strip()}}_${s}" >> prog - done - echo "_rust_eh_personality {{self.mod_name().strip()}}__rust_eh_personality" >> prog - llvm-objcopy --redefine-syms=prog ${l} - rm prog -done - rm -r .git # generate _openssl.c @@ -90,50 +67,13 @@ OUT_DIR=${SRC}/autogen_cffi ${BIN}/python3 _cffi_src/build_openssl.py {% block move_to_output %} {{super()}} -pushd ${BUILD}-msan -{% if self.executable().strip() %} - -{% for arch in self.msan_platforms().strip().split('\n') %} - mkdir -p ${OUTPUT}/target/{{arch}}-msan/release - cp {{arch}}/release/{{self.executable().strip()}} ${OUTPUT}/target/{{arch}}-msan/release -{% endfor %} - -{% else %} -(find . -type f -name '*.a'; find . -type f -name '*.lib') | sed -e 's|./||' | grep -v '/deps/' | while read l; do - d=$(dirname ${l}) - d_msan=$(echo "${d}" | sed 's|^\([^/]*\)|\1-msan|') - o=${OUTPUT}/a/${d_msan} - mkdir -p ${o} - cp ${l} ${o}/ -done -{% endif %} -popd - cp ${SRC}/autogen_cffi/_openssl.c ${OUTPUT}/ -cp ${OUTPUT}/patches/msan.cpp ${OUTPUT}/ cat {{arcadia_root}}/contrib/libs/openssl/redef.h | awk '/#define/{print $2" "$3}' > ${OUTPUT}/openssl_redefines.txt cat {{arcadia_root}}/contrib/libs/openssl/redef.h | awk '/#define/{print "_"$2" _"$3}' >> ${OUTPUT}/openssl_redefines.txt find ${OUTPUT} -name '*.a' | xargs -L1 llvm-objcopy --redefine-syms=${OUTPUT}/openssl_redefines.txt rm ${OUTPUT}/openssl_redefines.txt - -find ${OUTPUT} -name '*.a' -path "*msan*" | xargs -L1 llvm-objcopy --redefine-sym=__msan_set_alloca_origin_with_descr=__safe_msan_set_alloca_origin_with_descr -find ${OUTPUT} -name '*.a' -path "*msan*" | xargs -L1 llvm-objcopy --redefine-sym=malloc=__safe_malloc -{% endblock %} - -{% block prepare_yamake %} -{{super()}} - -sed -i '/IF (FALSE)/r'<(cat < ${BIN}/target-cc-aarch exec ${BIN}/target-cc -nostdinc -target aarch64-unknown-linux-gnu -I{{arcadia_root}}/contrib/libs/musl/include -I{{arcadia_root}}/contrib/libs/musl/arch/aarch64 -I{{arcadia_root}}/contrib/libs/musl/arch/generic "\${@}" EOF -cat << EOF > ${BIN}/target-cc-msan -#!/usr/bin/env sh -exec ${CLANG} -DUSE_PYTHON3 -fsanitize=memory -I{{arcadia_root}} -I{{arcadia_root}}/contrib/libs/python/Include -I{{arcadia_root}}/contrib/libs/openssl/include -x c "\${@}" -EOF - -chmod +x ${BIN}/musl-gcc ${BIN}/target-cc ${BIN}/target-cc-aarch ${BIN}/target-cc-msan +chmod +x ${BIN}/musl-gcc ${BIN}/target-cc ${BIN}/target-cc-aarch # macOS diff --git a/contrib/python/cryptography/next/rust/msan.cpp b/contrib/python/cryptography/next/rust/msan.cpp deleted file mode 100644 index c653958f4b0..00000000000 --- a/contrib/python/cryptography/next/rust/msan.cpp +++ /dev/null @@ -1,23 +0,0 @@ -#include -#include - -typedef size_t uptr; -typedef uint32_t u32; - -// defined in clang{XX}-rt/msan -extern "C" void __msan_set_alloca_origin_with_descr(void *a, uptr size, u32 *id_ptr, char *descr); -extern "C" void __msan_unpoison(void *a, uptr size); - - -extern "C" void __safe_msan_set_alloca_origin_with_descr(void *a, uptr size, u32 *id_ptr, char *descr) { - __msan_set_alloca_origin_with_descr(a, size, id_ptr, descr); - __msan_unpoison(a, size); -} - - -extern "C" void* malloc(size_t size); -extern "C" void *__safe_malloc(size_t size) { - void* ret = malloc(size); - __msan_unpoison(ret, size); - return ret; -} diff --git a/contrib/python/cryptography/next/rust/patches/msan.cpp b/contrib/python/cryptography/next/rust/patches/msan.cpp deleted file mode 100644 index c653958f4b0..00000000000 --- a/contrib/python/cryptography/next/rust/patches/msan.cpp +++ /dev/null @@ -1,23 +0,0 @@ -#include -#include - -typedef size_t uptr; -typedef uint32_t u32; - -// defined in clang{XX}-rt/msan -extern "C" void __msan_set_alloca_origin_with_descr(void *a, uptr size, u32 *id_ptr, char *descr); -extern "C" void __msan_unpoison(void *a, uptr size); - - -extern "C" void __safe_msan_set_alloca_origin_with_descr(void *a, uptr size, u32 *id_ptr, char *descr) { - __msan_set_alloca_origin_with_descr(a, size, id_ptr, descr); - __msan_unpoison(a, size); -} - - -extern "C" void* malloc(size_t size); -extern "C" void *__safe_malloc(size_t size) { - void* ret = malloc(size); - __msan_unpoison(ret, size); - return ret; -} diff --git a/contrib/python/cryptography/next/rust/ya.make b/contrib/python/cryptography/next/rust/ya.make index 607916aaab1..4f3d4b559a8 100644 --- a/contrib/python/cryptography/next/rust/ya.make +++ b/contrib/python/cryptography/next/rust/ya.make @@ -16,11 +16,8 @@ ORIGINAL_SOURCE(https://github.com/pyca/cryptography/archive/refs/tags/41.0.6.ta IF (FALSE) ELSEIF (OS_LINUX AND ARCH_X86_64 AND NOT MUSL AND SANITIZER_TYPE == "memory") COPY_FILE(a/x86_64-unknown-linux-gnu-msan/release/libcryptography_rust.a libcryptography_rust.a) - SRCS( - msan.cpp - ) - CFLAGS( - -fsanitize-memory-track-origins=2 + PEERDIR( + contrib/restricted/rust_msan ) ELSEIF (OS_LINUX AND ARCH_X86_64 AND NOT MUSL) COPY_FILE(a/x86_64-unknown-linux-gnu/release/libcryptography_rust.a libcryptography_rust.a) diff --git a/contrib/python/deepmerge/py3/.dist-info/METADATA b/contrib/python/deepmerge/py3/.dist-info/METADATA index b5b331cb98a..627fadb4bb5 100644 --- a/contrib/python/deepmerge/py3/.dist-info/METADATA +++ b/contrib/python/deepmerge/py3/.dist-info/METADATA @@ -1,6 +1,6 @@ -Metadata-Version: 2.1 +Metadata-Version: 2.4 Name: deepmerge -Version: 2.0 +Version: 2.1.0 Summary: A toolset for deeply merging Python dictionaries. Author-email: Yusuke Tsutsumi License: MIT Licence @@ -19,15 +19,18 @@ Classifier: Typing :: Typed Requires-Python: >=3.8 Description-Content-Type: text/x-rst License-File: LICENSE -Requires-Dist: typing-extensions ; python_version <= "3.9" +Requires-Dist: typing_extensions; python_version <= "3.9" Provides-Extra: dev -Requires-Dist: validate-pyproject[all] ; extra == 'dev' -Requires-Dist: pyupgrade ; extra == 'dev' -Requires-Dist: black ; extra == 'dev' -Requires-Dist: mypy ; extra == 'dev' -Requires-Dist: pytest ; extra == 'dev' -Requires-Dist: build ; extra == 'dev' -Requires-Dist: twine ; extra == 'dev' +Requires-Dist: validate-pyproject[all]; extra == "dev" +Requires-Dist: pyupgrade; extra == "dev" +Requires-Dist: black; extra == "dev" +Requires-Dist: mypy; extra == "dev" +Requires-Dist: pytest; extra == "dev" +Requires-Dist: build; extra == "dev" +Requires-Dist: twine; extra == "dev" +Requires-Dist: sphinx; extra == "dev" +Requires-Dist: sphinx-rtd-theme; extra == "dev" +Dynamic: license-file ========= deepmerge diff --git a/contrib/python/deepmerge/py3/deepmerge/_version.py b/contrib/python/deepmerge/py3/deepmerge/_version.py index 39e8a810ad2..514df6bc7bf 100644 --- a/contrib/python/deepmerge/py3/deepmerge/_version.py +++ b/contrib/python/deepmerge/py3/deepmerge/_version.py @@ -1,16 +1,24 @@ -# file generated by setuptools_scm +# file generated by vcs-versioning # don't change, don't track in version control -TYPE_CHECKING = False -if TYPE_CHECKING: - from typing import Tuple, Union - VERSION_TUPLE = Tuple[Union[int, str], ...] -else: - VERSION_TUPLE = object +from __future__ import annotations + +__all__ = [ + "__version__", + "__version_tuple__", + "version", + "version_tuple", + "__commit_id__", + "commit_id", +] version: str __version__: str -__version_tuple__: VERSION_TUPLE -version_tuple: VERSION_TUPLE +__version_tuple__: tuple[int | str, ...] +version_tuple: tuple[int | str, ...] +commit_id: str | None +__commit_id__: str | None + +__version__ = version = '2.1.0' +__version_tuple__ = version_tuple = (2, 1, 0) -__version__ = version = '2.0' -__version_tuple__ = version_tuple = (2, 0) +__commit_id__ = commit_id = None diff --git a/contrib/python/deepmerge/py3/deepmerge/extended_set.py b/contrib/python/deepmerge/py3/deepmerge/extended_set.py index 63b6e823738..a692c019c36 100644 --- a/contrib/python/deepmerge/py3/deepmerge/extended_set.py +++ b/contrib/python/deepmerge/py3/deepmerge/extended_set.py @@ -1,4 +1,4 @@ -from typing import Sequence, Any +from typing import Sequence, Any, Dict, List class ExtendedSet(set): @@ -13,10 +13,18 @@ class ExtendedSet(set): """ def __init__(self, elements: Sequence) -> None: - self._values_by_hash = {self._hash_element(e): e for e in elements} + # Elements are grouped into buckets keyed by their (possibly lossy) + # hash. Membership and insertion compare a candidate against the + # members of the matching bucket using equality, so two distinct + # elements that merely collide on the hash are not treated as equal. + self._buckets: Dict[int, List[Any]] = {} + for element in elements: + self._insert(element) def _insert(self, element: Any) -> None: - self._values_by_hash[self._hash_element(element)] = element + bucket = self._buckets.setdefault(self._hash_element(element), []) + if not any(element == existing for existing in bucket): + bucket.append(element) return def _hash_element(self, element: Any) -> int: @@ -29,4 +37,7 @@ class ExtendedSet(set): return hash(str(element)) def __contains__(self, obj: Any) -> bool: - return self._hash_element(obj) in self._values_by_hash + bucket = self._buckets.get(self._hash_element(obj)) + if bucket is None: + return False + return any(obj == existing for existing in bucket) diff --git a/contrib/python/deepmerge/py3/deepmerge/strategy/__init__.py b/contrib/python/deepmerge/py3/deepmerge/strategy/__init__.py index d62ea7ce11b..7dfb2e050a5 100644 --- a/contrib/python/deepmerge/py3/deepmerge/strategy/__init__.py +++ b/contrib/python/deepmerge/py3/deepmerge/strategy/__init__.py @@ -4,3 +4,14 @@ from .fallback import FallbackStrategies from .list import ListStrategies from .set import SetStrategies from .type_conflict import TypeConflictStrategies + +__all__ = [ + "StrategyList", + "StrategyCallable", + "StrategyListInitable", + "DictStrategies", + "FallbackStrategies", + "ListStrategies", + "SetStrategies", + "TypeConflictStrategies", +] diff --git a/contrib/python/deepmerge/py3/deepmerge/strategy/fallback.py b/contrib/python/deepmerge/py3/deepmerge/strategy/fallback.py index 7cfbaaadfc1..109b21c951b 100644 --- a/contrib/python/deepmerge/py3/deepmerge/strategy/fallback.py +++ b/contrib/python/deepmerge/py3/deepmerge/strategy/fallback.py @@ -5,7 +5,6 @@ from typing import Any, TypeVar import deepmerge.merger from .core import StrategyList - T = TypeVar("T") diff --git a/contrib/python/deepmerge/py3/deepmerge/tests/strategy/test_list.py b/contrib/python/deepmerge/py3/deepmerge/tests/strategy/test_list.py index 7211757c363..db26d584885 100644 --- a/contrib/python/deepmerge/py3/deepmerge/tests/strategy/test_list.py +++ b/contrib/python/deepmerge/py3/deepmerge/tests/strategy/test_list.py @@ -43,3 +43,23 @@ def test_strategy_append_similar_dict(custom_merger): result = custom_merger.merge(base, nxt) assert result == [{"bar": "bob", "foo": "baz"}, {"x": "y"}] + + +def test_strategy_append_unique_keeps_hash_colliding_dicts(custom_merger): + """append_unique must keep distinct dicts even when their lossy + "key:value" hash collides (e.g. ``{"a": "1", "b": "2"}`` and + ``{"a": "1,b:2"}`` both hash to ``"a:1,b:2"``), and must not treat an + ``int`` value as equal to its ``str`` form. + """ + assert custom_merger.merge([{"a": "1", "b": "2"}], [{"a": "1,b:2"}]) == [ + {"a": "1", "b": "2"}, + {"a": "1,b:2"}, + ] + assert custom_merger.merge([{"k": 1}], [{"k": "1"}]) == [{"k": 1}, {"k": "1"}] + + +def test_strategy_append_unique_hashable_hash_collision(custom_merger): + """A hashable element must not be considered present just because a + different element shares its hash (e.g. ``hash(-1) == hash(-2)``). + """ + assert custom_merger.merge([-1], [-2]) == [-1, -2] diff --git a/contrib/python/deepmerge/py3/deepmerge/tests/test_full.py b/contrib/python/deepmerge/py3/deepmerge/tests/test_full.py index bc6bdee8a30..c9246ffbb7e 100644 --- a/contrib/python/deepmerge/py3/deepmerge/tests/test_full.py +++ b/contrib/python/deepmerge/py3/deepmerge/tests/test_full.py @@ -58,3 +58,40 @@ def test_subtypes(): result = always_merger.merge(base, next) assert dict(result) == {"foo": "value", "bar": "value2", "baz": ["a", "b"]} + + +@pytest.mark.parametrize( + "initializer,expectation", + [ + (None, {"foo": "value", "bar": "value3", "baz": ["a", "b", "c"]}), + ({}, {"foo": "value", "bar": "value3", "baz": ["a", "b", "c"]}), + ( + {"init": "value"}, + {"foo": "value", "bar": "value3", "baz": ["a", "b", "c"], "init": "value"}, + ), + ], + ids=["no initilaizer", "empty initializer", "set initializer"], +) +def test_reduce(initializer, expectation): + # Given + from functools import reduce + + base = {"foo": "value", "baz": ["a"]} + next = {"bar": "value2", "baz": ["b"]} + more = {"bar": "value3", "baz": ["c"]} + + list_to_merge = [base, next, more] + + # When + if initializer is None: + result = reduce(always_merger.merge, list_to_merge) + else: + result = reduce(always_merger.merge, list_to_merge, initializer) + + # Then + assert result == expectation + + if initializer is None: + assert result is base + else: + assert result is not base diff --git a/contrib/python/deepmerge/py3/ya.make b/contrib/python/deepmerge/py3/ya.make index 1cb42fc56ae..367b4da494b 100644 --- a/contrib/python/deepmerge/py3/ya.make +++ b/contrib/python/deepmerge/py3/ya.make @@ -4,7 +4,7 @@ PY3_LIBRARY() SUBSCRIBER(g:python-contrib) -VERSION(2.0) +VERSION(2.1.0) LICENSE(MIT) diff --git a/contrib/python/hypothesis/py3/.dist-info/METADATA b/contrib/python/hypothesis/py3/.dist-info/METADATA index 087eb11eaaa..2efad59a484 100644 --- a/contrib/python/hypothesis/py3/.dist-info/METADATA +++ b/contrib/python/hypothesis/py3/.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: hypothesis -Version: 6.155.2 +Version: 6.155.3 Summary: The property-based testing library for Python Author-email: "David R. MacIver and Zac Hatfield-Dodds" License-Expression: MPL-2.0 diff --git a/contrib/python/hypothesis/py3/hypothesis/core.py b/contrib/python/hypothesis/py3/hypothesis/core.py index 44418c255ed..5c8b279464b 100644 --- a/contrib/python/hypothesis/py3/hypothesis/core.py +++ b/contrib/python/hypothesis/py3/hypothesis/core.py @@ -1205,6 +1205,7 @@ class StateForActualGivenExecution: ``StopTest`` must be a fatal error, and should stop the entire engine. """ trace: Trace = frozenset() + backend_cannot_proceed = False try: with Tracer(should_trace=self._should_trace()) as tracer: try: @@ -1233,7 +1234,16 @@ class StateForActualGivenExecution: # This was unexpected, meaning that the assume was flaky. # Report it as such. raise self._flaky_replay_to_failure(err, e) from None - except (StopTest, BackendCannotProceed): + except BackendCannotProceed: + # The engine discards this iteration entirely (see engine.py, + # "we're pretending this never happened"), so we shouldn't emit a + # test_case observation for it either -- otherwise an alternative + # backend that aborts before running the test (e.g. crosshair when + # it has exhausted its paths) surfaces a spurious, draw-less + # "passed" observation with an empty representation. + backend_cannot_proceed = True + raise + except StopTest: # The engine knows how to handle this control exception, so it's # OK to re-raise it. raise @@ -1320,7 +1330,7 @@ class StateForActualGivenExecution: except BackendCannotProceed: data.events = {} - if observability_enabled(): + if observability_enabled() and not backend_cannot_proceed: if runner := getattr(self, "_runner", None): phase = runner._current_phase else: # pragma: no cover # in case of messing with internals diff --git a/contrib/python/hypothesis/py3/hypothesis/version.py b/contrib/python/hypothesis/py3/hypothesis/version.py index b71a3693b09..175cbc5c08a 100644 --- a/contrib/python/hypothesis/py3/hypothesis/version.py +++ b/contrib/python/hypothesis/py3/hypothesis/version.py @@ -8,5 +8,5 @@ # v. 2.0. If a copy of the MPL was not distributed with this file, You can # obtain one at https://mozilla.org/MPL/2.0/. -__version_info__ = (6, 155, 2) +__version_info__ = (6, 155, 3) __version__ = ".".join(map(str, __version_info__)) diff --git a/contrib/python/hypothesis/py3/ya.make b/contrib/python/hypothesis/py3/ya.make index 59ebb1c2ba5..8ab046f972a 100644 --- a/contrib/python/hypothesis/py3/ya.make +++ b/contrib/python/hypothesis/py3/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(6.155.2) +VERSION(6.155.3) LICENSE(MPL-2.0) diff --git a/contrib/python/scramp/.dist-info/METADATA b/contrib/python/scramp/.dist-info/METADATA index 0699f73b120..d3b2cda4342 100644 --- a/contrib/python/scramp/.dist-info/METADATA +++ b/contrib/python/scramp/.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: scramp -Version: 1.4.8 +Version: 1.4.9 Summary: An implementation of the SCRAM protocol. Project-URL: Homepage, https://codeberg.org/tlocke/scramp Author: The Contributors @@ -54,6 +54,7 @@ Scramp supports the following mechanisms: * [OpenSSF Scorecard](#openssf-scorecard) * [Doing A Release Of Scramp](#doing-a-release-of-scramp) * [Release Notes](#release-notes) + * [Version 1.4.9, 2026-06-19](#version-149-2026-06-19) * [Version 1.4.8, 2026-01-06](#version-148-2026-01-06) * [Version 1.4.7, 2026-01-04](#version-147-2026-01-04) * [Version 1.4.6, 2025-07-05](#version-146-2025-07-05) @@ -383,6 +384,14 @@ Run `tox` to make sure all tests pass, then update the release notes, then do: ## Release Notes +### Version 1.4.9, 2026-06-19 + +- Fix typo in error message. +* Strictly validate on base 64 decoding +* Handle escaping in usernames according to the spec. +* [SASLprep](https://www.rfc-editor.org/info/rfc8265/): Raise exception rather than using assert + + ### Version 1.4.8, 2026-01-06 - Use the [`secrets`](https://docs.python.org/3/library/secrets.html) module from diff --git a/contrib/python/scramp/README.md b/contrib/python/scramp/README.md index dc6707f325d..1ccdd745c56 100644 --- a/contrib/python/scramp/README.md +++ b/contrib/python/scramp/README.md @@ -33,6 +33,7 @@ Scramp supports the following mechanisms: * [OpenSSF Scorecard](#openssf-scorecard) * [Doing A Release Of Scramp](#doing-a-release-of-scramp) * [Release Notes](#release-notes) + * [Version 1.4.9, 2026-06-19](#version-149-2026-06-19) * [Version 1.4.8, 2026-01-06](#version-148-2026-01-06) * [Version 1.4.7, 2026-01-04](#version-147-2026-01-04) * [Version 1.4.6, 2025-07-05](#version-146-2025-07-05) @@ -362,6 +363,14 @@ Run `tox` to make sure all tests pass, then update the release notes, then do: ## Release Notes +### Version 1.4.9, 2026-06-19 + +- Fix typo in error message. +* Strictly validate on base 64 decoding +* Handle escaping in usernames according to the spec. +* [SASLprep](https://www.rfc-editor.org/info/rfc8265/): Raise exception rather than using assert + + ### Version 1.4.8, 2026-01-06 - Use the [`secrets`](https://docs.python.org/3/library/secrets.html) module from diff --git a/contrib/python/scramp/scramp/__init__.py b/contrib/python/scramp/scramp/__init__.py index 4d7a7083e5e..1391c72297b 100644 --- a/contrib/python/scramp/scramp/__init__.py +++ b/contrib/python/scramp/scramp/__init__.py @@ -2,10 +2,10 @@ from importlib.metadata import version from scramp.core import ( ScramClient, - ScramException, ScramMechanism, make_channel_binding, ) +from scramp.exceptions import ScramException __all__ = ["ScramClient", "ScramException", "ScramMechanism", "make_channel_binding"] diff --git a/contrib/python/scramp/scramp/core.py b/contrib/python/scramp/scramp/core.py index 0c645a8cba9..7bfd362914a 100644 --- a/contrib/python/scramp/scramp/core.py +++ b/contrib/python/scramp/scramp/core.py @@ -23,6 +23,7 @@ from stringprep import ( from asn1crypto.x509 import Certificate +from scramp.exceptions import ScramException from scramp.utils import b64dec, b64enc, h, hmac, uenc, xor # https://tools.ietf.org/html/rfc5802 @@ -58,16 +59,6 @@ def _check_stage(Stages, current_stage, next_stage): ) -class ScramException(Exception): - def __init__(self, message, server_error=None): - super().__init__(message) - self.server_error = server_error - - def __str__(self): - s_str = "" if self.server_error is None else f": {self.server_error}" - return super().__str__() + s_str - - MECHANISMS = ( "SCRAM-SHA-1", "SCRAM-SHA-1-PLUS", @@ -405,7 +396,11 @@ def _make_cbind_input(channel_binding, use_binding): raise ScramException(f"The gs2_cbind_flag '{gs2_cbind_flag}' is not recognized") -def _parse_message(msg, desc, *validations): +def _print_set(s): + return "{" + ", ".join(sorted(set(s))) + "}" + + +def _parse_message(msg, desc, *expected_attr_sets): m = {} for p in msg.split(","): if len(p) < 2 or p[1] != "=": @@ -414,33 +409,35 @@ def _parse_message(msg, desc, *validations): f"each attribute must start with a letter followed by a '='", SERVER_ERROR_OTHER_ERROR, ) - m[p[0]] = p[2:] - - m = {e[0]: e[2:] for e in msg.split(",")} - - keystr = "".join(m.keys()) - for validation in validations: - if keystr == validation: - return m + k = p[0] + if k in m: + raise ScramException( + f"Duplicate attributes not allowed in message. The duplicated " + f"attribute is {k}. ", + SERVER_ERROR_OTHER_ERROR, + ) + m[k] = p[2:] - if len(validations) == 1: - val_str = f"'{validations[0]}'" - else: - val_str = f"one of {validations}" + attr_set = m.keys() + if attr_set in expected_attr_sets: + return m raise ScramException( - f"Malformed {desc} message. Expected the attribute list to be {val_str} but " - f"found '{keystr}'", + f"Malformed {desc} message. Expected the attribute set to be one of " + f"[{', '.join([_print_set(s) for s in expected_attr_sets])}] but found " + f"{_print_set(attr_set)}", SERVER_ERROR_OTHER_ERROR, ) def _get_client_first(username, c_nonce, channel_binding, use_binding): try: - u = saslprep(username) + prepped_u = saslprep(username) except ScramException as e: raise ScramException(e.args[0], SERVER_ERROR_INVALID_USERNAME_ENCODING) + u = prepped_u.replace("=", "=3D").replace(",", "=2C") + bare = ",".join((f"n={u}", f"r={c_nonce}")) _, gs2_header = _make_gs2_header(channel_binding, use_binding) return bare, gs2_header + bare @@ -469,7 +466,7 @@ def _set_client_first(client_first, s_nonce, channel_binding, use_binding): if gs2_char == "y": if channel_binding is not None: raise ScramException( - "Recieved GS2 flag 'y' which indicates that the client doesn't think " + "Received GS2 flag 'y' which indicates that the client doesn't think " "the server supports channel binding, but in fact it does", SERVER_ERROR_SERVER_DOES_SUPPORT_CHANNEL_BINDING, ) @@ -508,11 +505,11 @@ def _set_client_first(client_first, s_nonce, channel_binding, use_binding): ) client_first_bare = client_first[second_comma + 1 :] - msg = _parse_message(client_first_bare, "client first bare", "nr") + msg = _parse_message(client_first_bare, "client first bare", {"n", "r"}) c_nonce = msg["r"] nonce = c_nonce + s_nonce - user = msg["n"] + user = msg["n"].replace("=2C", ",").replace("=3D", "=") return nonce, user, client_first_bare, upgrade_mechanism @@ -522,7 +519,7 @@ def _get_server_first(nonce, salt, iterations): def _set_server_first(server_first, c_nonce): - msg = _parse_message(server_first, "server first", "rsi") + msg = _parse_message(server_first, "server first", {"r", "s", "i"}) if "e" in msg: raise ScramException(f"The server returned the error: {msg['e']}") @@ -567,7 +564,6 @@ def _get_client_final( SERVER_ERROR_INVALID_ENCODING = "invalid-encoding" SERVER_ERROR_EXTENSIONS_NOT_SUPPORTED = "extensions-not-supported" SERVER_ERROR_INVALID_PROOF = "invalid-proof" -SERVER_ERROR_INVALID_ENCODING = "invalid-encoding" SERVER_ERROR_CHANNEL_BINDINGS_DONT_MATCH = "channel-bindings-dont-match" SERVER_ERROR_SERVER_DOES_SUPPORT_CHANNEL_BINDING = "server-does-support-channel-binding" SERVER_ERROR_SERVER_DOES_NOT_SUPPORT_CHANNEL_BINDING = ( @@ -592,7 +588,7 @@ def _set_client_final( channel_binding, use_binding, ): - msg = _parse_message(client_final, "client final", "crp") + msg = _parse_message(client_final, "client final", {"c", "r", "p"}) chan_binding = msg["c"] nonce = msg["r"] @@ -623,7 +619,7 @@ def _get_server_final(server_signature, error): def _set_server_final(message, server_signature): - msg = _parse_message(message, "server final", "v", "e") + msg = _parse_message(message, "server final", {"v"}, {"e"}) if "e" in msg: raise ScramException(f"The server returned the error: {msg['e']}") @@ -668,12 +664,11 @@ def saslprep(source): # check for prohibited output # stringprep tables A.1, B.1, C.1.2, C.2 - C.9 for c in data: - # check for chars mapping stage should have removed - assert not in_table_b1(c), "failed to strip B.1 in mapping stage" - assert not in_table_c12(c), "failed to replace C.1.2 in mapping stage" - - # check for forbidden chars for f, msg in ( + # check for chars mapping stage should have removed + (in_table_b1, "failed to strip B.1 in mapping stage"), + (in_table_c12, "failed to replace C.1.2 in mapping stage"), + # check for forbidden chars (in_table_a1, "unassigned code points forbidden"), (in_table_c21_c22, "control characters forbidden"), (in_table_c3, "private use characters forbidden"), diff --git a/contrib/python/scramp/scramp/exceptions.py b/contrib/python/scramp/scramp/exceptions.py new file mode 100644 index 00000000000..a1e585769a4 --- /dev/null +++ b/contrib/python/scramp/scramp/exceptions.py @@ -0,0 +1,8 @@ +class ScramException(Exception): + def __init__(self, message, server_error=None): + super().__init__(message) + self.server_error = server_error + + def __str__(self): + s_str = "" if self.server_error is None else f": {self.server_error}" + return super().__str__() + s_str diff --git a/contrib/python/scramp/scramp/utils.py b/contrib/python/scramp/scramp/utils.py index 7fcd365f6b3..da864d2fe2f 100644 --- a/contrib/python/scramp/scramp/utils.py +++ b/contrib/python/scramp/scramp/utils.py @@ -1,6 +1,9 @@ +import binascii import hmac as hmaca from base64 import b64decode, b64encode +from scramp.exceptions import ScramException + def hmac(hf, key, msg): return hmaca.new(key, msg=msg, digestmod=hf).digest() @@ -19,7 +22,10 @@ def b64enc(binary): def b64dec(string): - return b64decode(string) + try: + return b64decode(string, validate=True) + except binascii.Error as e: + raise ScramException("Invalid base64 encoding.") from e def uenc(string): diff --git a/contrib/python/scramp/ya.make b/contrib/python/scramp/ya.make index 74bbaca906b..9453f63f0af 100644 --- a/contrib/python/scramp/ya.make +++ b/contrib/python/scramp/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(1.4.8) +VERSION(1.4.9) LICENSE(MIT-0) @@ -16,6 +16,7 @@ PY_SRCS( TOP_LEVEL scramp/__init__.py scramp/core.py + scramp/exceptions.py scramp/utils.py ) -- cgit v1.3 From 0a6ad8975632e0d3260c7e66f6295e5f1ed38eb9 Mon Sep 17 00:00:00 2001 From: robot-piglet Date: Thu, 9 Jul 2026 01:59:16 +0300 Subject: Intermediate changes commit_hash:84e4bb7342d3d78316fd09d0e25bd5290245afd4 --- contrib/python/prettytable/py3/.dist-info/METADATA | 123 +++- contrib/python/prettytable/py3/README.md | 116 ++- .../prettytable/py3/patches/01-fix-tests.patch | 6 + .../python/prettytable/py3/prettytable/__init__.py | 7 +- .../python/prettytable/py3/prettytable/_version.py | 26 +- .../prettytable/py3/prettytable/colortable.py | 35 +- .../prettytable/py3/prettytable/prettytable.py | 802 +++++++++++++-------- .../py3/tests/data/table_align_center.txt | 6 + .../py3/tests/data/table_align_left.txt | 6 + .../py3/tests/data/table_align_right.txt | 6 + .../py3/tests/data/table_ansi_colors.txt | 7 + .../prettytable/py3/tests/data/table_cjk.txt | 6 + .../py3/tests/data/table_combining_chars.txt | 6 + .../py3/tests/data/table_complex_emoji.txt | 7 + .../prettytable/py3/tests/test_colortable.py | 4 +- contrib/python/prettytable/py3/tests/test_html.py | 138 ++-- contrib/python/prettytable/py3/tests/test_json.py | 10 +- .../python/prettytable/py3/tests/test_mediawiki.py | 10 +- .../prettytable/py3/tests/test_prettytable.py | 578 +++++++++++---- contrib/python/prettytable/py3/tests/test_rst.py | 97 +++ .../python/prettytable/py3/tests/test_sorting.py | 59 +- contrib/python/prettytable/py3/tests/test_style.py | 223 +++++- contrib/python/prettytable/py3/ya.make | 2 +- 23 files changed, 1653 insertions(+), 627 deletions(-) create mode 100644 contrib/python/prettytable/py3/patches/01-fix-tests.patch create mode 100644 contrib/python/prettytable/py3/tests/data/table_align_center.txt create mode 100644 contrib/python/prettytable/py3/tests/data/table_align_left.txt create mode 100644 contrib/python/prettytable/py3/tests/data/table_align_right.txt create mode 100644 contrib/python/prettytable/py3/tests/data/table_ansi_colors.txt create mode 100644 contrib/python/prettytable/py3/tests/data/table_cjk.txt create mode 100644 contrib/python/prettytable/py3/tests/data/table_combining_chars.txt create mode 100644 contrib/python/prettytable/py3/tests/data/table_complex_emoji.txt create mode 100644 contrib/python/prettytable/py3/tests/test_rst.py (limited to 'contrib/python') diff --git a/contrib/python/prettytable/py3/.dist-info/METADATA b/contrib/python/prettytable/py3/.dist-info/METADATA index 6c57a35f2bc..85d2d452c4b 100644 --- a/contrib/python/prettytable/py3/.dist-info/METADATA +++ b/contrib/python/prettytable/py3/.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: prettytable -Version: 3.17.0 +Version: 3.18.0 Summary: A simple Python library for easily displaying tabular data in a visually appealing ASCII table format Project-URL: Changelog, https://github.com/prettytable/prettytable/releases Project-URL: Funding, https://tidelift.com/subscription/pkg/pypi-prettytable?utm_source=pypi-prettytable&utm_medium=pypi @@ -18,16 +18,17 @@ Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Classifier: Programming Language :: Python :: 3.15 +Classifier: Programming Language :: Python :: 3.16 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Text Processing Classifier: Typing :: Typed Requires-Python: >=3.10 -Requires-Dist: wcwidth +Requires-Dist: wcwidth>=0.3.5 Provides-Extra: tests -Requires-Dist: pytest; extra == 'tests' Requires-Dist: pytest-cov; extra == 'tests' Requires-Dist: pytest-lazy-fixtures; extra == 'tests' +Requires-Dist: pytest>=9; extra == 'tests' Description-Content-Type: text/markdown # PrettyTable @@ -61,11 +62,11 @@ PrettyTable lets you print tables in an attractive ASCII form: Install via pip: - python -m pip install -U prettytable + python3 -m pip install -U prettytable Install latest development version: - python -m pip install -U git+https://github.com/prettytable/prettytable + python3 -m pip install -U git+https://github.com/prettytable/prettytable Or from `requirements.txt`: @@ -135,13 +136,10 @@ method, which takes two arguments - a string which is the name for the field the you are adding corresponds to, and a list or tuple which contains the column data: ```python -table.add_column("City name", -["Adelaide","Brisbane","Darwin","Hobart","Sydney","Melbourne","Perth"]) +table.add_column("City name", ["Adelaide", "Brisbane", "Darwin", "Hobart", "Sydney", "Melbourne", "Perth"]) table.add_column("Area", [1295, 5905, 112, 1357, 2058, 1566, 5386]) -table.add_column("Population", [1158259, 1857594, 120900, 205556, 4336374, 3806092, -1554769]) -table.add_column("Annual Rainfall",[600.5, 1146.4, 1714.7, 619.5, 1214.8, 646.9, -869.4]) +table.add_column("Population", [1158259, 1857594, 120900, 205556, 4336374, 3806092, 1554769]) +table.add_column("Annual Rainfall",[600.5, 1146.4, 1714.7, 619.5, 1214.8, 646.9, 869.4]) ``` #### Mixing and matching @@ -255,6 +253,30 @@ def my_cli_function(table_format: str = 'text'): print(table.get_formatted_string(table_format)) ``` +#### Paginating your table + +If you have a large table and want to split it into multiple pages, you can use the +`paginate` method. This method splits the table into pages of a specified length and +separates them with a line break character (by default, a form feed character `\f`): + +```python +paginated_string = table.paginate(page_length=10) +print(paginated_string) +``` + +The `page_length` parameter controls how many rows appear on each page (default: 58). +The `line_break` parameter specifies the string used to separate pages (default: `"\f"` +form feed). You can also pass any keyword arguments that are accepted by `get_string()` +to control the formatting of each page: + +```python +paginated_string = table.paginate(page_length=20, line_break="\n\n---\n\n", border=True) +print(paginated_string) +``` + +This is particularly useful when printing large tables to a terminal or when you want to +format output for pagination in documents. + #### Controlling which data gets displayed If you like, you can restrict the output of `print(table)` or `table.get_string` to only @@ -497,6 +519,39 @@ to get a table like this: Any added dividers will be removed if a table is sorted. +### Adding a title to your table + +You can add a title to your table with the `title` attribute: + +```python +table.title = "Australian Cities" +``` + +Titles can span multiple lines by including `\n` in the string: + +```python +table.title = "Australian Cities\nPopulation Data" +print(table) +``` + +This produces: + +``` ++-------------------------------------------------+ +| Australian Cities | +| Population Data | ++-----------+------+------------+-----------------+ +| City name | Area | Population | Annual Rainfall | ++-----------+------+------------+-----------------+ +| Adelaide | 1295 | 1158259 | 600.5 | +| Brisbane | 5905 | 1857594 | 1146.4 | +| Darwin | 112 | 120900 | 1714.7 | ++-----------+------+------------+-----------------+ +``` + +Each line of the title is centered and bordered independently. Multiline titles also +work with HTML output (using `
` in the `` tag) and Markdown output. + ### Changing the appearance of your table - the easy way By default, PrettyTable produces ASCII tables that look like the ones used in SQL @@ -525,6 +580,8 @@ In addition to `MARKDOWN` you can use these in-built styles: - `MSWORD_FRIENDLY` - A format which works nicely with Microsoft Word's "Convert to table" feature - `ORGMODE` - A table style that fits [Org mode](https://orgmode.org/) syntax +- `RST` - A table style that produces reStructuredText + [grid tables](https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html#tables) - `SINGLE_BORDER` and `DOUBLE_BORDER` - Styles that use continuous single/double border lines with Box drawing characters for a fancier display on terminal @@ -642,6 +699,36 @@ print(table.get_string(border=False)) print(table) ``` +#### Custom format example (with _colors_) + +The `custom_format` attribute allows you to define a function for custom cell value +formatting. For example, the following code demonstrates how to apply a single function +across all columns to display strings in `blue`, positive numbers in `green`, and +negative numbers in `red`, all with a precision of two decimal places. Your custom +function can also inspect the `field` parameter to apply field-specific formatting. + +```python +from prettytable import PrettyTable +from typing import Any +from termcolor import colored + +def _colored(field: str, val: Any) -> str: + if isinstance(val, (int, float)): + if val >= 0: + return colored(f"{val:.2f}", "green") + return colored(f"{val:.2f}", "red") + elif isinstance(val, str): + return colored(val, "blue") + return f"{val}" + +table = PrettyTable(("Name", "Overtime")) +table.custom_format = _colored + +for row in [["John Doe", 5.0], ["Jane Smith", -2.0]]: + table.add_row([row[0], row[1]]) +print(table) +``` + ### Changing the appearance of your table - with _colors_! PrettyTable has the functionality of printing your table with ANSI color codes. This @@ -806,10 +893,18 @@ new_table = old_table[0:5] ## Contributing -After editing files, use the [Black](https://github.com/psf/black) linter to auto-format +After editing files, use at least [Black](https://github.com/psf/black) to auto-format changed lines. ```sh -python -m pip install black -black prettytable*.py +python3 -m pip install black +black . +``` + +To run all pre-commit checks, linters, formatters (including Black), and tests use +[tox](https://github.com/tox-dev/tox): + +```sh +python3 -m pip install tox +tox ``` diff --git a/contrib/python/prettytable/py3/README.md b/contrib/python/prettytable/py3/README.md index a06e6c35cf6..52c3c694cba 100644 --- a/contrib/python/prettytable/py3/README.md +++ b/contrib/python/prettytable/py3/README.md @@ -29,11 +29,11 @@ PrettyTable lets you print tables in an attractive ASCII form: Install via pip: - python -m pip install -U prettytable + python3 -m pip install -U prettytable Install latest development version: - python -m pip install -U git+https://github.com/prettytable/prettytable + python3 -m pip install -U git+https://github.com/prettytable/prettytable Or from `requirements.txt`: @@ -103,13 +103,10 @@ method, which takes two arguments - a string which is the name for the field the you are adding corresponds to, and a list or tuple which contains the column data: ```python -table.add_column("City name", -["Adelaide","Brisbane","Darwin","Hobart","Sydney","Melbourne","Perth"]) +table.add_column("City name", ["Adelaide", "Brisbane", "Darwin", "Hobart", "Sydney", "Melbourne", "Perth"]) table.add_column("Area", [1295, 5905, 112, 1357, 2058, 1566, 5386]) -table.add_column("Population", [1158259, 1857594, 120900, 205556, 4336374, 3806092, -1554769]) -table.add_column("Annual Rainfall",[600.5, 1146.4, 1714.7, 619.5, 1214.8, 646.9, -869.4]) +table.add_column("Population", [1158259, 1857594, 120900, 205556, 4336374, 3806092, 1554769]) +table.add_column("Annual Rainfall",[600.5, 1146.4, 1714.7, 619.5, 1214.8, 646.9, 869.4]) ``` #### Mixing and matching @@ -223,6 +220,30 @@ def my_cli_function(table_format: str = 'text'): print(table.get_formatted_string(table_format)) ``` +#### Paginating your table + +If you have a large table and want to split it into multiple pages, you can use the +`paginate` method. This method splits the table into pages of a specified length and +separates them with a line break character (by default, a form feed character `\f`): + +```python +paginated_string = table.paginate(page_length=10) +print(paginated_string) +``` + +The `page_length` parameter controls how many rows appear on each page (default: 58). +The `line_break` parameter specifies the string used to separate pages (default: `"\f"` +form feed). You can also pass any keyword arguments that are accepted by `get_string()` +to control the formatting of each page: + +```python +paginated_string = table.paginate(page_length=20, line_break="\n\n---\n\n", border=True) +print(paginated_string) +``` + +This is particularly useful when printing large tables to a terminal or when you want to +format output for pagination in documents. + #### Controlling which data gets displayed If you like, you can restrict the output of `print(table)` or `table.get_string` to only @@ -465,6 +486,39 @@ to get a table like this: Any added dividers will be removed if a table is sorted. +### Adding a title to your table + +You can add a title to your table with the `title` attribute: + +```python +table.title = "Australian Cities" +``` + +Titles can span multiple lines by including `\n` in the string: + +```python +table.title = "Australian Cities\nPopulation Data" +print(table) +``` + +This produces: + +``` ++-------------------------------------------------+ +| Australian Cities | +| Population Data | ++-----------+------+------------+-----------------+ +| City name | Area | Population | Annual Rainfall | ++-----------+------+------------+-----------------+ +| Adelaide | 1295 | 1158259 | 600.5 | +| Brisbane | 5905 | 1857594 | 1146.4 | +| Darwin | 112 | 120900 | 1714.7 | ++-----------+------+------------+-----------------+ +``` + +Each line of the title is centered and bordered independently. Multiline titles also +work with HTML output (using `
` in the `` tag) and Markdown output. + ### Changing the appearance of your table - the easy way By default, PrettyTable produces ASCII tables that look like the ones used in SQL @@ -493,6 +547,8 @@ In addition to `MARKDOWN` you can use these in-built styles: - `MSWORD_FRIENDLY` - A format which works nicely with Microsoft Word's "Convert to table" feature - `ORGMODE` - A table style that fits [Org mode](https://orgmode.org/) syntax +- `RST` - A table style that produces reStructuredText + [grid tables](https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html#tables) - `SINGLE_BORDER` and `DOUBLE_BORDER` - Styles that use continuous single/double border lines with Box drawing characters for a fancier display on terminal @@ -610,6 +666,36 @@ print(table.get_string(border=False)) print(table) ``` +#### Custom format example (with _colors_) + +The `custom_format` attribute allows you to define a function for custom cell value +formatting. For example, the following code demonstrates how to apply a single function +across all columns to display strings in `blue`, positive numbers in `green`, and +negative numbers in `red`, all with a precision of two decimal places. Your custom +function can also inspect the `field` parameter to apply field-specific formatting. + +```python +from prettytable import PrettyTable +from typing import Any +from termcolor import colored + +def _colored(field: str, val: Any) -> str: + if isinstance(val, (int, float)): + if val >= 0: + return colored(f"{val:.2f}", "green") + return colored(f"{val:.2f}", "red") + elif isinstance(val, str): + return colored(val, "blue") + return f"{val}" + +table = PrettyTable(("Name", "Overtime")) +table.custom_format = _colored + +for row in [["John Doe", 5.0], ["Jane Smith", -2.0]]: + table.add_row([row[0], row[1]]) +print(table) +``` + ### Changing the appearance of your table - with _colors_! PrettyTable has the functionality of printing your table with ANSI color codes. This @@ -774,10 +860,18 @@ new_table = old_table[0:5] ## Contributing -After editing files, use the [Black](https://github.com/psf/black) linter to auto-format +After editing files, use at least [Black](https://github.com/psf/black) to auto-format changed lines. ```sh -python -m pip install black -black prettytable*.py +python3 -m pip install black +black . +``` + +To run all pre-commit checks, linters, formatters (including Black), and tests use +[tox](https://github.com/tox-dev/tox): + +```sh +python3 -m pip install tox +tox ``` diff --git a/contrib/python/prettytable/py3/patches/01-fix-tests.patch b/contrib/python/prettytable/py3/patches/01-fix-tests.patch new file mode 100644 index 00000000000..c439ba2902c --- /dev/null +++ b/contrib/python/prettytable/py3/patches/01-fix-tests.patch @@ -0,0 +1,6 @@ +--- contrib/python/prettytable/py3/tests/test_style.py (index) ++++ contrib/python/prettytable/py3/tests/test_style.py (working tree) +@@ -18 +18,2 @@ from prettytable.prettytable import _str_block_width +-DATA_DIR = os.path.join(os.path.dirname(__file__), "data") ++import yatest.common as yc ++DATA_DIR = os.path.join(os.path.dirname(yc.source_path(__file__)), "data") diff --git a/contrib/python/prettytable/py3/prettytable/__init__.py b/contrib/python/prettytable/py3/prettytable/__init__.py index f28c03658ab..5876dcfeac4 100644 --- a/contrib/python/prettytable/py3/prettytable/__init__.py +++ b/contrib/python/prettytable/py3/prettytable/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +__lazy_modules__ = {"prettytable._version", "prettytable.prettytable"} from ._version import __version__ from .prettytable import ( # noqa: F401 @@ -31,6 +31,11 @@ from .prettytable import ( # noqa: F401 from_mediawiki, ) +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import Any + + __all__ = [ "ALL", "DEFAULT", diff --git a/contrib/python/prettytable/py3/prettytable/_version.py b/contrib/python/prettytable/py3/prettytable/_version.py index b6f2ce2e738..d38ec03f655 100644 --- a/contrib/python/prettytable/py3/prettytable/_version.py +++ b/contrib/python/prettytable/py3/prettytable/_version.py @@ -1,5 +1,6 @@ -# file generated by setuptools-scm +# file generated by vcs-versioning # don't change, don't track in version control +from __future__ import annotations __all__ = [ "__version__", @@ -10,25 +11,14 @@ __all__ = [ "commit_id", ] -TYPE_CHECKING = False -if TYPE_CHECKING: - from typing import Tuple - from typing import Union - - VERSION_TUPLE = Tuple[Union[int, str], ...] - COMMIT_ID = Union[str, None] -else: - VERSION_TUPLE = object - COMMIT_ID = object - version: str __version__: str -__version_tuple__: VERSION_TUPLE -version_tuple: VERSION_TUPLE -commit_id: COMMIT_ID -__commit_id__: COMMIT_ID +__version_tuple__: tuple[int | str, ...] +version_tuple: tuple[int | str, ...] +commit_id: str | None +__commit_id__: str | None -__version__ = version = '3.17.0' -__version_tuple__ = version_tuple = (3, 17, 0) +__version__ = version = '3.18.0' +__version_tuple__ = version_tuple = (3, 18, 0) __commit_id__ = commit_id = None diff --git a/contrib/python/prettytable/py3/prettytable/colortable.py b/contrib/python/prettytable/py3/prettytable/colortable.py index 52e1fa20a4c..0f9d417a4e3 100644 --- a/contrib/python/prettytable/py3/prettytable/colortable.py +++ b/contrib/python/prettytable/py3/prettytable/colortable.py @@ -2,6 +2,11 @@ from __future__ import annotations from .prettytable import PrettyTable +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Sequence + from typing import ClassVar, Final + try: from colorama import init @@ -10,10 +15,18 @@ except ImportError: pass -RESET_CODE = "\x1b[0m" +RESET_CODE: Final = "\x1b[0m" class Theme: + default_color: Final[str] + vertical_char: Final[str] + vertical_color: Final[str] + horizontal_char: Final[str] + horizontal_color: Final[str] + junction_char: Final[str] + junction_color: Final[str] + def __init__( self, default_color: str = "", @@ -44,50 +57,50 @@ class Theme: class Themes: - DEFAULT = Theme() - DYSLEXIA_FRIENDLY = Theme( + DEFAULT: ClassVar[Theme] = Theme() + DYSLEXIA_FRIENDLY: ClassVar[Theme] = Theme( default_color="38;5;223", vertical_color="38;5;22", horizontal_color="38;5;22", junction_color="38;5;58", ) - EARTH = Theme( + EARTH: ClassVar[Theme] = Theme( default_color="33", vertical_color="38;5;94", horizontal_color="38;5;22", junction_color="38;5;130", ) - GLARE_REDUCTION = Theme( + GLARE_REDUCTION: ClassVar[Theme] = Theme( default_color="38;5;252", vertical_color="38;5;240", horizontal_color="38;5;240", junction_color="38;5;246", ) - HIGH_CONTRAST = Theme( + HIGH_CONTRAST: ClassVar[Theme] = Theme( default_color="97", vertical_color="91", horizontal_color="94", junction_color="93", ) - LAVENDER = Theme( + LAVENDER: ClassVar[Theme] = Theme( default_color="38;5;183", vertical_color="35", horizontal_color="38;5;147", junction_color="38;5;219", ) - OCEAN = Theme( + OCEAN: ClassVar[Theme] = Theme( default_color="96", vertical_color="34", horizontal_color="34", junction_color="36", ) - OCEAN_DEEP = Theme( + OCEAN_DEEP: ClassVar[Theme] = Theme( default_color="96", vertical_color="34", horizontal_color="36", junction_color="94", ) - PASTEL = Theme( + PASTEL: ClassVar[Theme] = Theme( default_color="38;5;223", vertical_color="38;5;152", horizontal_color="38;5;187", @@ -96,7 +109,7 @@ class Themes: class ColorTable(PrettyTable): - def __init__(self, field_names=None, **kwargs) -> None: + def __init__(self, field_names: Sequence[str] | None = None, **kwargs) -> None: super().__init__(field_names=field_names, **kwargs) # TODO: Validate option diff --git a/contrib/python/prettytable/py3/prettytable/prettytable.py b/contrib/python/prettytable/py3/prettytable/prettytable.py index 1b6c964652c..d3cdf990fb7 100644 --- a/contrib/python/prettytable/py3/prettytable/prettytable.py +++ b/contrib/python/prettytable/py3/prettytable/prettytable.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # Copyright (c) 2009-2014, Luke Maurits # All rights reserved. # With contributions from: @@ -33,22 +31,71 @@ from __future__ import annotations -import io -import re from enum import IntEnum from functools import lru_cache -from html.parser import HTMLParser -from typing import Any, Literal, TypedDict, cast +from typing import Any, Literal, cast TYPE_CHECKING = False if TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence - from sqlite3 import Cursor - from typing import Final, TypeAlias + from typing import Final, TypeAlias, TypedDict from _typeshed import SupportsRichComparison from typing_extensions import Self + class OptionsType(TypedDict): + title: str | None + start: int + end: int | None + fields: Sequence[str | None] | None + header: bool + use_header_width: bool + border: bool + preserve_internal_border: bool + sortby: str | None + reversesort: bool + sort_key: Callable[[RowType], SupportsRichComparison] + row_filter: Callable[[RowType], bool] + attributes: dict[str, str] + format: bool + hrules: HRuleStyle + vrules: VRuleStyle + int_format: str | dict[str, str] | None + float_format: str | dict[str, str] | None + custom_format: ( + Callable[[str, Any], str] | dict[str, Callable[[str, Any], str]] | None + ) + min_table_width: int | None + max_table_width: int | None + padding_width: int + left_padding_width: int | None + right_padding_width: int | None + vertical_char: str + horizontal_char: str + horizontal_align_char: str + header_horizontal_char: str | None + junction_char: str + header_style: HeaderStyleType + xhtml: bool + print_empty: bool + oldsortslice: bool + top_junction_char: str + bottom_junction_char: str + right_junction_char: str + left_junction_char: str + top_right_junction_char: str + top_left_junction_char: str + bottom_right_junction_char: str + bottom_left_junction_char: str + align: dict[str, AlignType] + valign: dict[str, VAlignType] + min_width: int | dict[str, int] | None + max_width: int | dict[str, int] | None + none_format: str | dict[str, str | None] | None + escape_header: bool + escape_data: bool + break_on_hyphens: bool + class HRuleStyle(IntEnum): FRAME = 0 @@ -71,6 +118,7 @@ class TableStyle(IntEnum): ORGMODE = 14 DOUBLE_BORDER = 15 SINGLE_BORDER = 16 + RST = 17 RANDOM = 20 @@ -97,63 +145,39 @@ VAlignType: TypeAlias = Literal["t", "m", "b"] HeaderStyleType: TypeAlias = Literal["cap", "title", "upper", "lower"] | None -class OptionsType(TypedDict): - title: str | None - start: int - end: int | None - fields: Sequence[str | None] | None - header: bool - use_header_width: bool - border: bool - preserve_internal_border: bool - sortby: str | None - reversesort: bool - sort_key: Callable[[RowType], SupportsRichComparison] - row_filter: Callable[[RowType], bool] - attributes: dict[str, str] - format: bool - hrules: HRuleStyle - vrules: VRuleStyle - int_format: str | dict[str, str] | None - float_format: str | dict[str, str] | None - custom_format: ( - Callable[[str, Any], str] | dict[str, Callable[[str, Any], str]] | None - ) - min_table_width: int | None - max_table_width: int | None - padding_width: int - left_padding_width: int | None - right_padding_width: int | None - vertical_char: str - horizontal_char: str - horizontal_align_char: str - junction_char: str - header_style: HeaderStyleType - xhtml: bool - print_empty: bool - oldsortslice: bool - top_junction_char: str - bottom_junction_char: str - right_junction_char: str - left_junction_char: str - top_right_junction_char: str - top_left_junction_char: str - bottom_right_junction_char: str - bottom_left_junction_char: str - align: dict[str, AlignType] - valign: dict[str, VAlignType] - min_width: int | dict[str, int] | None - max_width: int | dict[str, int] | None - none_format: str | dict[str, str | None] | None - escape_header: bool - escape_data: bool - break_on_hyphens: bool - - -# ANSI colour codes -_re = re.compile(r"\033\[[0-9;]*m|\033\(B") -# OSC 8 hyperlinks -_osc8_re = re.compile(r"\033\]8;;.*?\033\\(.*?)\033\]8;;\033\\") +class ObservableDict(dict[str, Any]): + """A dictionary that notifies a callback when items are set or changed. + + This dictionary subclass allows setting a callback function that will be + invoked whenever an item is set or its value changes. This is used to + maintain consistency between related format dictionaries. + """ + + def __init__(self, *args, **kwargs): + """Initialize the observable dictionary. + + Arguments: + *args: Positional arguments passed to dict.__init__ + **kwargs: Keyword arguments passed to dict.__init__ + """ + super().__init__(*args, **kwargs) + self.callback = None + + def __setitem__(self, key: str, value: Any): + """Set an item and trigger callback if value changed. + + Sets the item in the dictionary and calls the callback function + (if set) with the key, old value, and new value when the value + actually changes. + + Arguments: + key: The dictionary key + value: The new value to set + """ + old_value = self.get(key) + if self.callback is not None and old_value != value: + self.callback(key, old_value, value) + super().__setitem__(key, value) @lru_cache @@ -196,6 +220,7 @@ class PrettyTable: _vertical_char: str _horizontal_char: str _horizontal_align_char: str | None + _header_horizontal_char: str | None _junction_char: str _top_junction_char: str | None _bottom_junction_char: str | None @@ -253,6 +278,8 @@ class PrettyTable: vertical_char - single character string used to draw vertical lines horizontal_char - single character string used to draw horizontal lines horizontal_align_char - single character string used to indicate alignment + header_horizontal_char - single character string used to draw the header + separator, or None to use the same as horizontal_char junction_char - single character string used to draw line junctions top_junction_char - single character string used to draw top line junctions bottom_junction_char - @@ -274,7 +301,7 @@ class PrettyTable: valign - default valign for each row (None, "t", "m" or "b") reversesort - True or False to sort in descending or ascending order oldsortslice - Slice rows before sorting in the "old style" - break_on_hyphens - Whether long lines are broken on hypens or not, default: True + break_on_hyphens - Whether long lines are broken on hyphens or not, default: True """ self.encoding = kwargs.get("encoding", "UTF-8") @@ -282,13 +309,6 @@ class PrettyTable: self._field_names: list[str] = [] self._rows: list[RowType] = [] self._dividers: list[bool] = [] - self.align = {} - self.valign = {} - self.max_width = {} - self.min_width = {} - self.int_format = {} - self.float_format = {} - self.custom_format = {} self._style = None # Options @@ -320,6 +340,7 @@ class PrettyTable: "vertical_char", "horizontal_char", "horizontal_align_char", + "header_horizontal_char", "junction_char", "header_style", "xhtml", @@ -343,7 +364,31 @@ class PrettyTable: "break_on_hyphens", ] - self._none_format: dict[str, str | None] = {} + self._none_format: dict[str, str | None] = ObservableDict() + self._none_format.callback = self._remove_custom_format_callback + + self._int_format: dict[str, str | None] = ObservableDict() + self._int_format.callback = self._remove_custom_format_callback + + self._float_format: dict[str, str | None] = ObservableDict() + self._float_format.callback = self._remove_custom_format_callback + + self._custom_format: dict[str, Callable[[str, Any], str]] = ObservableDict() + self._custom_format.callback = self._custom_format_callback + + self._align: dict[str, str | None] = ObservableDict() + self._align[BASE_ALIGN_VALUE] = "c" + self._align.callback = self._align_callback + + self._valign: dict[str, str | None] = ObservableDict() + self._valign.callback = self._valign_callback + + self._max_width: dict[str, int | None] = ObservableDict() + self._max_width.callback = self._max_width_callback + + self._min_width: dict[str, int | None] = ObservableDict() + self._min_width.callback = self._min_width_callback + self._kwargs = {} if field_names: self.field_names = field_names @@ -414,6 +459,7 @@ class PrettyTable: self._vertical_char = kwargs["vertical_char"] or "|" self._horizontal_char = kwargs["horizontal_char"] or "-" self._horizontal_align_char = kwargs["horizontal_align_char"] + self._header_horizontal_char = kwargs["header_horizontal_char"] self._junction_char = kwargs["junction_char"] or "+" self._top_junction_char = kwargs["top_junction_char"] self._bottom_junction_char = kwargs["bottom_junction_char"] @@ -457,36 +503,20 @@ class PrettyTable: ) def _justify(self, text: str, width: int, align: AlignType) -> str: - excess = width - _str_block_width(text) + import wcwidth + if align == "l": - return text + excess * " " + return wcwidth.ljust(text, width) elif align == "r": - return excess * " " + text + return wcwidth.rjust(text, width) else: - if excess % 2: - # Uneven padding - # Put more space on right if text is of odd length... - if _str_block_width(text) % 2: - return (excess // 2) * " " + text + (excess // 2 + 1) * " " - # and more space on left if text is of even length - else: - return (excess // 2 + 1) * " " + text + (excess // 2) * " " - # Why distribute extra space this way? To match the behaviour of - # the inbuilt str.center() method. - else: - # Equal padding on either side - return (excess // 2) * " " + text + (excess // 2) * " " + return wcwidth.center(text, width) def __getattr__(self, name): if name == "rowcount": return len(self._rows) elif name == "colcount": - if self._field_names: - return len(self._field_names) - elif self._rows: - return len(self._rows[0]) - else: - return 0 + return len(self._field_names) else: raise AttributeError(name) @@ -494,8 +524,7 @@ class PrettyTable: new = PrettyTable() new.field_names = self.field_names for attr in self._options: - setattr(new, "_" + attr, getattr(self, "_" + attr)) - setattr(new, "_align", getattr(self, "_align")) + setattr(new, f"_{attr}", getattr(self, f"_{attr}")) if isinstance(index, slice): for row in self._rows[index]: new.add_row(row) @@ -587,6 +616,7 @@ class PrettyTable: "vertical_char", "horizontal_char", "horizontal_align_char", + "header_horizontal_char", "junction_char", "top_junction_char", "bottom_junction_char", @@ -701,7 +731,7 @@ class PrettyTable: def _validate_function(self, name, val): try: - assert hasattr(val, "__call__") + assert callable(val) except AssertionError: msg = f"Invalid value for {name}. Must be a function." raise ValueError(msg) @@ -782,9 +812,7 @@ class PrettyTable: val - The alternative representation to be used for None values """ - if not self._field_names: - self._none_format = {} - elif isinstance(val, str): + if isinstance(val, str): for field in self._field_names: self._none_format[field] = None self._validate_none_format(val) @@ -795,8 +823,7 @@ class PrettyTable: self._validate_none_format(fval) self._none_format[field] = fval else: - for field in self._field_names: - self._none_format[field] = None + self._none_format.clear() @property def field_names(self) -> list[str]: @@ -822,8 +849,8 @@ class PrettyTable: for old_name, new_name in zip(old_names, val): self._align[new_name] = self._align[old_name] for old_name in old_names: - if old_name not in self._align: - self._align.pop(old_name) + if old_name not in val: + self._align.pop(old_name, None) elif self._align: for field_name in self._field_names: self._align[field_name] = self._align[BASE_ALIGN_VALUE] @@ -833,11 +860,25 @@ class PrettyTable: for old_name, new_name in zip(old_names, val): self._valign[new_name] = self._valign[old_name] for old_name in old_names: - if old_name not in self._valign: - self._valign.pop(old_name) + if old_name not in val: + self._valign.pop(old_name, None) else: self.valign = "t" + def _align_callback(self, field_name, old_value, new_value): + """Callback to call validators if dict attrs are modified. + + This callback is triggered when a field is modified from align dict and + calls the validator for the new value. + + Arguments: + field_name: Name of the field being modified + old_value: Previous value (unused) + new_value: New value (unused) + + """ + self._validate_align(new_value) + @property def align(self) -> dict[str, AlignType]: """Controls alignment of fields @@ -849,7 +890,6 @@ class PrettyTable: @align.setter def align(self, val: AlignType | dict[str, AlignType] | None) -> None: if isinstance(val, str): - self._validate_align(val) if not self._field_names: self._align = {BASE_ALIGN_VALUE: val} else: @@ -857,14 +897,26 @@ class PrettyTable: self._align[field] = val elif isinstance(val, dict) and val: for field, fval in val.items(): - self._validate_align(fval) self._align[field] = fval + elif self._field_names: + for field in self._field_names: + self._align[field] = "c" else: - if not self._field_names: - self._align = {BASE_ALIGN_VALUE: "c"} - else: - for field in self._field_names: - self._align[field] = "c" + self._align = {BASE_ALIGN_VALUE: "c"} + + def _valign_callback(self, field_name, old_value, new_value): + """Callback to call validators if dict attrs are modified. + + This callback is triggered when a field is modified from valign dict + and calls the validator for the new value. + + Arguments: + field_name: Name of the field being modified + old_value: Previous value (unused) + new_value: New value (unused) + + """ + self._validate_valign(new_value) @property def valign(self) -> dict[str, VAlignType]: @@ -877,19 +929,31 @@ class PrettyTable: @valign.setter def valign(self, val: VAlignType | dict[str, VAlignType] | None) -> None: if not self._field_names: - self._valign = {} + self._valign.clear() if isinstance(val, str): - self._validate_valign(val) for field in self._field_names: self._valign[field] = val elif isinstance(val, dict) and val: for field, fval in val.items(): - self._validate_valign(fval) self._valign[field] = fval else: for field in self._field_names: self._valign[field] = "t" + def _max_width_callback(self, field_name, old_value, new_value): + """Callback to call validators if dict attrs are modified. + + This callback is triggered when a field is modified from max_width dict + and calls the validator for the new value. + + Arguments: + field_name: Name of the field being modified + old_value: Previous value (unused) + new_value: New value (unused) + + """ + self._validate_option("max_width", new_value) + @property def max_width(self) -> dict[str, int]: """Controls maximum width of fields @@ -901,15 +965,27 @@ class PrettyTable: @max_width.setter def max_width(self, val: int | dict[str, int] | None) -> None: if isinstance(val, int): - self._validate_option("max_width", val) for field in self._field_names: self._max_width[field] = val elif isinstance(val, dict) and val: for field, fval in val.items(): - self._validate_option("max_width", fval) self._max_width[field] = fval else: - self._max_width = {} + self._max_width.clear() + + def _min_width_callback(self, field_name, old_value, new_value): + """Callback to call validators if dict attrs are modified. + + This callback is triggered when a field is modified from min_width dict + and calls the validator for the new value. + + Arguments: + field_name: Name of the field being modified + old_value: Previous value (unused) + new_value: New value (unused) + + """ + self._validate_option("min_width", new_value) @property def min_width(self) -> dict[str, int]: @@ -922,15 +998,13 @@ class PrettyTable: @min_width.setter def min_width(self, val: int | dict[str, int] | None) -> None: if isinstance(val, int): - self._validate_option("min_width", val) for field in self._field_names: self._min_width[field] = val elif isinstance(val, dict) and val: for field, fval in val.items(): - self._validate_option("min_width", fval) self._min_width[field] = fval else: - self._min_width = {} + self._min_width.clear() @property def min_table_width(self) -> int | None: @@ -1180,7 +1254,7 @@ class PrettyTable: self._validate_option("int_format", fval) self._int_format[field] = fval else: - self._int_format = {} + self._int_format.clear() @property def float_format(self) -> dict[str, str]: @@ -1201,7 +1275,41 @@ class PrettyTable: self._validate_option("float_format", fval) self._float_format[field] = fval else: - self._float_format = {} + self._float_format.clear() + + def _remove_custom_format_callback(self, field_name, old_value, new_value): + """Callback to remove custom format when a field is removed from format dicts. + + This callback is triggered when a field is removed from _none_format, + _int_format, or _float_format dictionaries, and removes the corresponding + entry from _custom_format if it exists. + + Arguments: + field_name: Name of the field being removed + old_value: Previous value (unused) + new_value: New value (unused) + """ + if field_name in self._custom_format: + del self._custom_format[field_name] + + def _custom_format_callback(self, field_name, old_value, new_value): + """Callback to remove std formats when a field is removed from custom_format. + + This callback is triggered when a field is removed from _custom_format + dictionary, and removes the corresponding entries from _float_format, + _int_format, and _none_format if they exist. + + Arguments: + field_name: Name of the field being removed + old_value: Previous value (unused) + new_value: New value (unused) + """ + if field_name in self._float_format: + del self._float_format[field_name] + if field_name in self._int_format: + del self._int_format[field_name] + if field_name in self._none_format: + del self._none_format[field_name] @property def custom_format(self) -> dict[str, Callable[[str, Any], str]]: @@ -1216,13 +1324,24 @@ class PrettyTable: self, val: Callable[[str, Any], str] | dict[str, Callable[[str, Any], str]] | None, ): + """Set custom format for columns using callable functions. + + Arguments: + val: Can be: + - None: Clears all custom formats + - dict: Dictionary mapping field names to callable functions + - callable: A single callable function applied to all fields + + Raises: + TypeError: If val is not None, a dict, or a callable + """ if val is None: - self._custom_format = {} + self._custom_format.clear() elif isinstance(val, dict): - for k, v in val.items(): - self._validate_function(f"custom_value.{k}", v) - self._custom_format = val - elif hasattr(val, "__call__"): + for field, fval in val.items(): + self._validate_function(f"custom_value.{field}", fval) + self._custom_format[field] = fval + elif callable(val): self._validate_function("custom_value", val) for field in self._field_names: self._custom_format[field] = val @@ -1309,7 +1428,7 @@ class PrettyTable: Arguments: horizontal_align_char - single character string used to indicate alignment""" - return self._bottom_left_junction_char or self.junction_char + return self._horizontal_align_char or self.junction_char @horizontal_align_char.setter def horizontal_align_char(self, val: str) -> None: @@ -1317,6 +1436,23 @@ class PrettyTable: self._validate_option("horizontal_align_char", val) self._horizontal_align_char = val + @property + def header_horizontal_char(self) -> str | None: + """The character used when printing the header separator line + + Arguments: + + header_horizontal_char - single character string used to draw the header + separator, or None to use the same as horizontal_char""" + return self._header_horizontal_char + + @header_horizontal_char.setter + def header_horizontal_char(self, val: str | None) -> None: + if val is not None: + val = str(val) + self._validate_option("header_horizontal_char", val) + self._header_horizontal_char = val + @property def junction_char(self) -> str: """The character used when printing table borders to draw line junctions @@ -1558,7 +1694,7 @@ class PrettyTable: options[option] = kwargs[option] else: options[option] = getattr(self, option) - return cast(OptionsType, options) + return cast("OptionsType", options) ############################## # PRESET STYLE LOGIC # @@ -1579,6 +1715,8 @@ class PrettyTable: self._set_double_border_style() elif style == TableStyle.SINGLE_BORDER: self._set_single_border_style() + elif style == TableStyle.RST: + self._set_rst_style() elif style == TableStyle.RANDOM: self._set_random_style() elif style != TableStyle.DEFAULT: @@ -1599,6 +1737,19 @@ class PrettyTable: self.junction_char = "|" self._horizontal_align_char = ":" + def _set_rst_style(self) -> None: + self.header = True + self.border = True + self._hrules = HRuleStyle.ALL + self.padding_width = 1 + self.left_padding_width = 1 + self.right_padding_width = 1 + self.vertical_char = "|" + self.junction_char = "+" + self.horizontal_char = "-" + self._horizontal_align_char = None + self.header_horizontal_char = "=" + def _set_default_style(self) -> None: self.header = True self.border = True @@ -1610,6 +1761,7 @@ class PrettyTable: self.vertical_char = "|" self.horizontal_char = "-" self._horizontal_align_char = None + self.header_horizontal_char = None self.junction_char = "+" self._top_junction_char = None self._bottom_junction_char = None @@ -1866,12 +2018,24 @@ class PrettyTable: return (f"%{self._float_format[field]}f") % value formatter = self._custom_format.get(field, (lambda f, v: str(v))) - return formatter(field, value) + # PrettyTable is unaware of a terminal's tabstops, and it does not know at + # what specific location of the screen it will be displayed, so it also cannot + # calculate tabstop positions or width: A '\t' character is variable-width, + # depending on the location of the screen it is displayed. + # + # Although wcwidth library functions like width() do measure tab control + # character as a width of 8, it would require PrettyTable to display the left + # margin of the table's contents to begin "at the tabstop" for the table + # contents and dividers to line up correctly. + # + # PrettyTable is "screen unaware", so it is best to alter tabstops to a fixed + # width, to allow same-width display anywhere on the screen. + return formatter(field, value).expandtabs() def _compute_table_width(self, options) -> int: if options["vrules"] == VRuleStyle.FRAME: table_width = 2 - if options["vrules"] == VRuleStyle.ALL: + elif options["vrules"] == VRuleStyle.ALL: table_width = 1 else: table_width = 0 @@ -1932,7 +2096,10 @@ class PrettyTable: # Are we under min_table_width or title width? if self._min_table_width or options["title"]: if options["title"]: - title_width = _str_block_width(options["title"]) + per_col_padding + title_width = ( + max(_str_block_width(line) for line in options["title"].split("\n")) + + per_col_padding + ) if options["vrules"] in (VRuleStyle.FRAME, VRuleStyle.ALL): title_width += 2 else: @@ -2058,7 +2225,8 @@ class PrettyTable: vertical_char - single character string used to draw vertical lines horizontal_char - single character string used to draw horizontal lines horizontal_align_char - single character string used to indicate alignment - junction_char - single character string used to draw line junctions + header_horizontal_char - single character string used to draw the header + separator, or None to use the same as horizontal_char junction_char - single character string used to draw line junctions top_junction_char - single character string used to draw top line junctions bottom_junction_char - @@ -2103,7 +2271,10 @@ class PrettyTable: # Add title title = options["title"] or self._title if title: - lines.append(self._stringify_title(title, options)) + if self._style != TableStyle.MARKDOWN: + lines.append(self._stringify_title(title, options)) + else: + lines.extend([f"**{line}**" for line in title.split("\n")] + [""]) # Add header or top of border if options["header"]: @@ -2158,12 +2329,12 @@ class PrettyTable: return "" lpad, rpad = self._get_padding_widths(options) if options["vrules"] in (VRuleStyle.ALL, VRuleStyle.FRAME): - bits = [options[where + "left_junction_char"]] # type: ignore[literal-required] + bits = [options[f"{where}left_junction_char"]] # type: ignore[literal-required] else: bits = [options["horizontal_char"]] # For tables with no data or fieldnames if not self._field_names: - bits.append(options[where + "right_junction_char"]) # type: ignore[literal-required] + bits.append(options[f"{where}right_junction_char"]) # type: ignore[literal-required] return "".join(bits) for field, width in zip(self._field_names, self._widths): if options["fields"] and field not in options["fields"]: @@ -2180,12 +2351,12 @@ class PrettyTable: bits.append(line) if options["vrules"] == VRuleStyle.ALL: - bits.append(options[where + "junction_char"]) # type: ignore[literal-required] + bits.append(options[f"{where}junction_char"]) # type: ignore[literal-required] else: bits.append(options["horizontal_char"]) if options["vrules"] in (VRuleStyle.ALL, VRuleStyle.FRAME): bits.pop() - bits.append(options[where + "right_junction_char"]) # type: ignore[literal-required] + bits.append(options[f"{where}right_junction_char"]) # type: ignore[literal-required] if options["preserve_internal_border"] and not options["border"]: bits = bits[1:-1] @@ -2194,7 +2365,6 @@ class PrettyTable: def _stringify_title(self, title: str, options: OptionsType) -> str: lines: list[str] = [] - lpad, rpad = self._get_padding_widths(options) if options["border"]: if options["vrules"] == VRuleStyle.ALL: options["vrules"] = VRuleStyle.FRAME @@ -2202,21 +2372,19 @@ class PrettyTable: options["vrules"] = VRuleStyle.ALL elif options["vrules"] == VRuleStyle.FRAME: lines.append(self._stringify_hrule(options, "top_")) - bits: list[str] = [] endpoint = ( options["vertical_char"] if options["vrules"] in (VRuleStyle.ALL, VRuleStyle.FRAME) and options["border"] else " " ) - bits.append(endpoint) - title = " " * lpad + title + " " * rpad lpad, rpad = self._get_padding_widths(options) sum_widths = sum([n + lpad + rpad + 1 for n in self._widths]) - - bits.append(self._justify(title, sum_widths - 1, "c")) - bits.append(endpoint) - lines.append("".join(bits)) + for title_line in title.split("\n"): + padded = " " * lpad + title_line + " " * rpad + lines.append( + endpoint + self._justify(padded, sum_widths - 1, "c") + endpoint + ) return "\n".join(lines) def _stringify_header(self, options: OptionsType) -> str: @@ -2287,11 +2455,18 @@ class PrettyTable: "hrules" ] != HRuleStyle.NONE: bits.append("\n") - bits.append(self._hrule) + if options["header_horizontal_char"]: + header_options = { + **options, + "horizontal_char": options["header_horizontal_char"], + } + bits.append(self._stringify_hrule(header_options)) # type: ignore[arg-type] + else: + bits.append(self._hrule) return "".join(bits) def _stringify_row(self, row: list[str], options: OptionsType, hrule: str) -> str: - import textwrap + import wcwidth for index, field, value, width in zip( range(len(row)), self._field_names, row, self._widths @@ -2306,19 +2481,17 @@ class PrettyTable: ): line = none_val if _str_block_width(line) > width: - line = textwrap.fill( - line, width, break_on_hyphens=options["break_on_hyphens"] + line = "\n".join( + wcwidth.wrap( + line, width, break_on_hyphens=options["break_on_hyphens"] + ) ) new_lines.append(line) lines = new_lines value = "\n".join(lines) row[index] = value - row_height = 0 - for c in row: - h = _get_size(c)[1] - if h > row_height: - row_height = h + row_height = max(_get_size(c)[1] for c in row) bits: list[list[str]] = [] lpad, rpad = self._get_padding_widths(options) @@ -2382,6 +2555,18 @@ class PrettyTable: return "\n".join(bits_str) def paginate(self, page_length: int = 58, line_break: str = "\f", **kwargs) -> str: + """Return string representation of table split into pages. + + Arguments: + + page_length - number of rows per page (default: 58) + line_break - string used to separate pages (default: "\f" form feed) + **kwargs - additional keyword arguments passed to get_string() method, + such as title, fields, header, border, etc. + + The table is split into pages of the specified length, with each page + separated by the line_break character. All formatting options available + in get_string() can be used via kwargs.""" pages: list[str] = [] kwargs["start"] = kwargs.get("start", 0) true_end = kwargs.get("end", self.rowcount) @@ -2406,6 +2591,7 @@ class PrettyTable: delimiter as a csv.writer keyword argument. """ import csv + import io options = self._get_options(kwargs) csv_options = { @@ -2422,7 +2608,7 @@ class PrettyTable: else: csv_writer.writerow(self._field_names) - rows = self._get_rows(options) + rows = self._format_rows(self._get_rows(options)) if options["fields"]: rows = [ [d for f, d in zip(self._field_names, row) if f in options["fields"]] @@ -2465,17 +2651,12 @@ class PrettyTable: objects.append(self.field_names) rows = self._get_rows(options) if options["fields"]: - for row in rows: - objects.append( - { - f: d - for f, d in zip(self._field_names, row) - if f in options["fields"] - } - ) + objects.extend( + {f: d for f, d in zip(self._field_names, row) if f in options["fields"]} + for row in rows + ) else: - for row in rows: - objects.append(dict(zip(self._field_names, row))) + objects.extend(dict(zip(self._field_names, row)) for row in rows) return json.dumps(objects, **json_options) @@ -2518,7 +2699,7 @@ class PrettyTable: styling options (True or False) escape_data - escapes the text within a data field (True or False) xhtml - print
tags if True,
tags if False - break_on_hyphens - Whether long lines are broken on hypens or not, default: True + break_on_hyphens - Whether long lines are broken on hyphens or not, default: True """ options = self._get_options(kwargs) @@ -2549,7 +2730,8 @@ class PrettyTable: # Title title = options["title"] or self._title if title: - lines.append(f" {escape(title)}") + caption = escape(title).replace("\n", linebreak) + lines.append(f" {caption}") # Headers if options["header"]: @@ -2635,7 +2817,8 @@ class PrettyTable: # Title title = options["title"] or self._title if title: - lines.append(f" {escape(title)}") + caption = escape(title).replace("\n", linebreak) + lines.append(f" {caption}") # Headers if options["header"]: @@ -2661,15 +2844,14 @@ class PrettyTable: lines.append(" ") rows = self._get_rows(options) formatted_rows = self._format_rows(rows) - aligns: list[str] = [] - valigns: list[str] = [] - for field in self._field_names: - aligns.append( - {"l": "left", "r": "right", "c": "center"}[self._align[field]] - ) - valigns.append( - {"t": "top", "m": "middle", "b": "bottom"}[self._valign[field]] - ) + aligns: list[str] = [ + {"l": "left", "r": "right", "c": "center"}[self._align[field]] + for field in self._field_names + ] + valigns: list[str] = [ + {"t": "top", "m": "middle", "b": "bottom"}[self._valign[field]] + for field in self._field_names + ] for row in formatted_rows: lines.append(" ") for field, datum, align, valign in zip( @@ -2734,7 +2916,6 @@ class PrettyTable: def _get_simple_latex_string(self, options: OptionsType) -> str: lines: list[str] = [] - wanted_fields = [] if options["fields"]: wanted_fields = [ field for field in self._field_names if field in options["fields"] @@ -2744,12 +2925,12 @@ class PrettyTable: alignments = "".join([self._align[field] for field in wanted_fields]) - begin_cmd = f"\\begin{{tabular}}{{{alignments}}}" + begin_cmd = rf"\begin{{tabular}}{{{alignments}}}" lines.append(begin_cmd) # Headers if options["header"]: - lines.append(" & ".join(wanted_fields) + " \\\\") + lines.append(" & ".join(wanted_fields) + r" \\") # Data rows = self._get_rows(options) @@ -2758,9 +2939,9 @@ class PrettyTable: wanted_data = [ d for f, d in zip(self._field_names, row) if f in wanted_fields ] - lines.append(" & ".join(wanted_data) + " \\\\") + lines.append(" & ".join(wanted_data) + r" \\") - lines.append("\\end{tabular}") + lines.append(r"\end{tabular}") return "\r\n".join(lines) @@ -2786,40 +2967,39 @@ class PrettyTable: VRuleStyle.ALL, VRuleStyle.FRAME, ]: - alignment_str = "|" + alignment_str + "|" + alignment_str = f"|{alignment_str}|" - begin_cmd = f"\\begin{{tabular}}{{{alignment_str}}}" + begin_cmd = rf"\begin{{tabular}}{{{alignment_str}}}" lines.append(begin_cmd) if options["border"] and options["hrules"] in [ HRuleStyle.ALL, HRuleStyle.FRAME, ]: - lines.append("\\hline") + lines.append(r"\hline") # Headers if options["header"]: - lines.append(" & ".join(wanted_fields) + " \\\\") + lines.append(" & ".join(wanted_fields) + r" \\") if (options["border"] or options["preserve_internal_border"]) and options[ "hrules" ] in [HRuleStyle.ALL, HRuleStyle.HEADER]: - lines.append("\\hline") + lines.append(r"\hline") # Data rows = self._get_rows(options) formatted_rows = self._format_rows(rows) - rows = self._get_rows(options) for row in formatted_rows: wanted_data = [ d for f, d in zip(self._field_names, row) if f in wanted_fields ] - lines.append(" & ".join(wanted_data) + " \\\\") + lines.append(" & ".join(wanted_data) + r" \\") if options["border"] and options["hrules"] == HRuleStyle.ALL: - lines.append("\\hline") + lines.append(r"\hline") if options["border"] and options["hrules"] == HRuleStyle.FRAME: - lines.append("\\hline") + lines.append(r"\hline") - lines.append("\\end{tabular}") + lines.append(r"\end{tabular}") return "\r\n".join(lines) @@ -2845,42 +3025,42 @@ class PrettyTable: options = self._get_options(kwargs) lines: list[str] = [] - if ( - options.get("attributes") - and isinstance(options["attributes"], dict) - and options["attributes"] - ): - attr_str = " ".join(f'{k}="{v}"' for k, v in options["attributes"].items()) - lines.append("{| " + attr_str) + attributes_option = options.get("attributes") + if attributes_option and isinstance(attributes_option, dict): + attr_str = " ".join(f'{k}="{v}"' for k, v in attributes_option.items()) + lines.append(f"{{| {attr_str}") else: lines.append('{| class="wikitable"') caption = options.get("title", self._title) if caption: - lines.append("|+ " + caption) + lines.append(f"|+ {caption}") + fields_option = options.get("fields") if options.get("header"): lines.append("|-") - headers = [] - fields_option = options.get("fields") - for field in self._field_names: - if fields_option is not None and field not in fields_option: - continue - headers.append(field) + if fields_option is None: + headers = self._field_names + else: + headers = [ + field for field in self._field_names if field in fields_option + ] if headers: header_line = " !! ".join(headers) - lines.append("! " + header_line) + lines.append(f"! {header_line}") rows = self._get_rows(options) formatted_rows = self._format_rows(rows) for row in formatted_rows: lines.append("|-") - cells = [] - fields_option = options.get("fields") - for field, cell in zip(self._field_names, row): - if fields_option is not None and field not in fields_option: - continue - cells.append(cell) + if fields_option is None: + cells = row + else: + cells = [ + cell + for field, cell in zip(self._field_names, row) + if field in fields_option + ] if cells: lines.append("| " + " || ".join(cells)) @@ -2897,8 +3077,7 @@ class PrettyTable: def _str_block_width(val: str) -> int: import wcwidth - val = _osc8_re.sub(r"\1", val) - return wcwidth.wcswidth(_re.sub("", val)) + return wcwidth.width(val) ############################## @@ -2941,7 +3120,7 @@ def from_csv(fp, field_names: Sequence[str] | None = None, **kwargs) -> PrettyTa return table -def from_db_cursor(cursor: Cursor, **kwargs) -> PrettyTable | None: +def from_db_cursor(cursor: Any, **kwargs) -> PrettyTable | None: if cursor.description: table = PrettyTable(**kwargs) table.field_names = [col[0] for col in cursor.description] @@ -2963,77 +3142,101 @@ def from_json(json_string: str | bytes, **kwargs) -> PrettyTable: return table -class TableHandler(HTMLParser): - def __init__(self, **kwargs) -> None: - HTMLParser.__init__(self) - self.kwargs = kwargs - self.tables: list[PrettyTable] = [] - self.last_row: list[str] = [] - self.rows: list[tuple[list[str], bool]] = [] - self.max_row_width = 0 - self.active: str | None = None - self.last_content = "" - self.is_last_row_header = False - self.colspan = 0 - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - self.active = tag - if tag == "th": - self.is_last_row_header = True - for key, value in attrs: - if key == "colspan": - self.colspan = int(value) # type: ignore[arg-type] - - def handle_endtag(self, tag: str) -> None: - if tag in ["th", "td"]: - stripped_content = self.last_content.strip() - self.last_row.append(stripped_content) - if self.colspan: - for _ in range(1, self.colspan): - self.last_row.append("") - self.colspan = 0 - - if tag == "tr": - self.rows.append((self.last_row, self.is_last_row_header)) - self.max_row_width = max(self.max_row_width, len(self.last_row)) - self.last_row = [] +def _make_table_handler(): + from html.parser import HTMLParser + + class _TableHandler(HTMLParser): + def __init__(self, **kwargs) -> None: + HTMLParser.__init__(self) + self.kwargs = kwargs + self.tables: list[PrettyTable] = [] + self.last_row: list[str] = [] + self.rows: list[tuple[list[str], bool]] = [] + self.max_row_width = 0 + self.active: str | None = None + self.last_content = "" self.is_last_row_header = False - if tag == "table": - table = self.generate_table(self.rows) - self.tables.append(table) - self.rows = [] - self.last_content = " " - self.active = None + self.colspan = 0 + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + self.active = tag + if tag == "th": + self.is_last_row_header = True + for key, value in attrs: + if key == "colspan": + self.colspan = int(value) # type: ignore[arg-type] + + def handle_endtag(self, tag: str) -> None: + if tag in ["th", "td"]: + stripped_content = self.last_content.strip() + self.last_row.append(stripped_content) + if self.colspan: + for _ in range(1, self.colspan): + self.last_row.append("") + self.colspan = 0 + + if tag == "tr": + self.rows.append((self.last_row, self.is_last_row_header)) + self.max_row_width = max(self.max_row_width, len(self.last_row)) + self.last_row = [] + self.is_last_row_header = False + if tag == "table": + table = self.generate_table(self.rows) + self.tables.append(table) + self.rows = [] + self.last_content = " " + self.active = None + + def handle_data(self, data: str) -> None: + self.last_content += data + + def generate_table(self, rows: list[tuple[list[str], bool]]) -> PrettyTable: + """ + Generates from a list of rows a PrettyTable object. + """ + table = PrettyTable(**self.kwargs) + for row in self.rows: + if len(row[0]) < self.max_row_width: + appends = self.max_row_width - len(row[0]) + for i in range(1, appends): + row[0].append("-") + + if row[1]: + self.make_fields_unique(row[0]) + table.field_names = row[0] + else: + table.add_row(row[0]) + return table - def handle_data(self, data: str) -> None: - self.last_content += data + def make_fields_unique(self, fields: list[str]) -> None: + """ + iterates over the row and make each field unique + """ + for i in range(len(fields)): + for j in range(i + 1, len(fields)): + if fields[i] == fields[j]: + fields[j] += "'" - def generate_table(self, rows: list[tuple[list[str], bool]]) -> PrettyTable: - """ - Generates from a list of rows a PrettyTable object. - """ - table = PrettyTable(**self.kwargs) - for row in self.rows: - if len(row[0]) < self.max_row_width: - appends = self.max_row_width - len(row[0]) - for i in range(1, appends): - row[0].append("-") - - if row[1]: - self.make_fields_unique(row[0]) - table.field_names = row[0] - else: - table.add_row(row[0]) - return table + return _TableHandler - def make_fields_unique(self, fields: list[str]) -> None: - """ - iterates over the row and make each field unique - """ - for i in range(len(fields)): - for j in range(i + 1, len(fields)): - if fields[i] == fields[j]: - fields[j] += "'" + +class TableHandler: + """Deprecated: use from_html or from_html_one instead.""" + + def __init__(self, **kwargs) -> None: + import warnings + + warnings.warn( + "TableHandler is deprecated and will be removed in a future release. " + "Use from_html() or from_html_one() instead.", + DeprecationWarning, + stacklevel=2, + ) + cls = _make_table_handler() + self.__class__ = cls + cls.__init__(self, **kwargs) def from_html(html_code: str, **kwargs) -> list[PrettyTable]: @@ -3041,8 +3244,7 @@ def from_html(html_code: str, **kwargs) -> list[PrettyTable]: Generates a list of PrettyTables from a string of HTML code. Each in the HTML becomes one PrettyTable object. """ - - parser = TableHandler(**kwargs) + parser = _make_table_handler()(**kwargs) parser.feed(html_code) return parser.tables @@ -3089,11 +3291,11 @@ def from_mediawiki(wiki_text: str, **kwargs) -> PrettyTable: if line.startswith("|+"): continue if line.startswith("!"): - header = [cell.strip() for cell in re.split(r"\s*!!\s*", line[1:])] + header = [cell.strip() for cell in line[1:].split("!!")] table.field_names = header continue if line.startswith("|"): - row_data = [cell.strip() for cell in re.split(r"\s*\|\|\s*", line[1:])] + row_data = [cell.strip() for cell in line[1:].split("||")] rows.append(row_data) continue @@ -3129,4 +3331,20 @@ def _warn_deprecation(name: str, module_globals: dict[str, Any]) -> Any: def __getattr__(name: str) -> Any: + if name == "OptionsType": + import warnings + from typing import TypedDict + + warnings.warn( + "OptionsType is deprecated and will be removed in a future release", + DeprecationWarning, + stacklevel=2, + ) + + class OptionsType(TypedDict): + pass + + globals()[name] = OptionsType + return OptionsType + return _warn_deprecation(name, module_globals=globals()) diff --git a/contrib/python/prettytable/py3/tests/data/table_align_center.txt b/contrib/python/prettytable/py3/tests/data/table_align_center.txt new file mode 100644 index 00000000000..c25becb19d7 --- /dev/null +++ b/contrib/python/prettytable/py3/tests/data/table_align_center.txt @@ -0,0 +1,6 @@ ++------+ +| Name | ++------+ +| 👨‍👩‍👧 | +| Hi | ++------+ diff --git a/contrib/python/prettytable/py3/tests/data/table_align_left.txt b/contrib/python/prettytable/py3/tests/data/table_align_left.txt new file mode 100644 index 00000000000..ad65ed94378 --- /dev/null +++ b/contrib/python/prettytable/py3/tests/data/table_align_left.txt @@ -0,0 +1,6 @@ ++------+ +| Name | ++------+ +| 👨‍👩‍👧 | +| Hi | ++------+ diff --git a/contrib/python/prettytable/py3/tests/data/table_align_right.txt b/contrib/python/prettytable/py3/tests/data/table_align_right.txt new file mode 100644 index 00000000000..3e1e73875d0 --- /dev/null +++ b/contrib/python/prettytable/py3/tests/data/table_align_right.txt @@ -0,0 +1,6 @@ ++------+ +| Name | ++------+ +| 👨‍👩‍👧 | +| Hi | ++------+ diff --git a/contrib/python/prettytable/py3/tests/data/table_ansi_colors.txt b/contrib/python/prettytable/py3/tests/data/table_ansi_colors.txt new file mode 100644 index 00000000000..4dc5d86f520 --- /dev/null +++ b/contrib/python/prettytable/py3/tests/data/table_ansi_colors.txt @@ -0,0 +1,7 @@ ++--------+-------+ +| Status | Count | ++--------+-------+ +| OK | 10 | +| Failed | 2 | +| Normal | 5 | ++--------+-------+ diff --git a/contrib/python/prettytable/py3/tests/data/table_cjk.txt b/contrib/python/prettytable/py3/tests/data/table_cjk.txt new file mode 100644 index 00000000000..e928b529282 --- /dev/null +++ b/contrib/python/prettytable/py3/tests/data/table_cjk.txt @@ -0,0 +1,6 @@ ++------+-------+ +| CJK | Width | ++------+-------+ +| 中文 | 4 | +| Test | 4 | ++------+-------+ diff --git a/contrib/python/prettytable/py3/tests/data/table_combining_chars.txt b/contrib/python/prettytable/py3/tests/data/table_combining_chars.txt new file mode 100644 index 00000000000..c0da2c2b05f --- /dev/null +++ b/contrib/python/prettytable/py3/tests/data/table_combining_chars.txt @@ -0,0 +1,6 @@ ++------+------+ +| Word | Lang | ++------+------+ +| café | FR | +| cafe | EN | ++------+------+ diff --git a/contrib/python/prettytable/py3/tests/data/table_complex_emoji.txt b/contrib/python/prettytable/py3/tests/data/table_complex_emoji.txt new file mode 100644 index 00000000000..2a39e8164f3 --- /dev/null +++ b/contrib/python/prettytable/py3/tests/data/table_complex_emoji.txt @@ -0,0 +1,7 @@ ++-------+--------+ +| Emoji | Name | ++-------+--------+ +| 👨‍👩‍👧 | Family | +| 🇺🇸 | USA | +| Hi | Text | ++-------+--------+ diff --git a/contrib/python/prettytable/py3/tests/test_colortable.py b/contrib/python/prettytable/py3/tests/test_colortable.py index 40a287169a0..5f25acf7c6b 100644 --- a/contrib/python/prettytable/py3/tests/test_colortable.py +++ b/contrib/python/prettytable/py3/tests/test_colortable.py @@ -157,7 +157,7 @@ class TestColorTableRendering: table.header = with_header table.add_row([1, 2, 3, 4, 5, 6]) - expected = header_str + "\n" + body_str + "\x1b[0m" + expected = f"{header_str}\n{body_str}\x1b[0m" result = str(table) assert expected == result @@ -170,7 +170,7 @@ class TestColorTableRendering: table.title = "Theme Test" table.add_row([1, 2, 3, 4, 5, 6]) - for theme_name, theme in vars(Themes).items(): + for theme in vars(Themes).values(): if isinstance(theme, Theme): table.theme = theme result = str(table) diff --git a/contrib/python/prettytable/py3/tests/test_html.py b/contrib/python/prettytable/py3/tests/test_html.py index 0c3ef03a21d..acfd31c2a00 100644 --- a/contrib/python/prettytable/py3/tests/test_html.py +++ b/contrib/python/prettytable/py3/tests/test_html.py @@ -26,9 +26,7 @@ class TestHtmlConstructor: class TestHtmlOutput: def test_html_output(self, helper_table: PrettyTable) -> None: result = helper_table.get_html_string() - assert ( - result.strip() - == """ + assert result.strip() == """
@@ -60,13 +58,10 @@ class TestHtmlOutput:
""".strip() - ) def test_html_output_formatted(self, helper_table: PrettyTable) -> None: result = helper_table.get_html_string(format=True) - assert ( - result.strip() - == """ + assert result.strip() == """ @@ -97,17 +92,14 @@ class TestHtmlOutput:
-""".strip() # noqa: E501 - ) +""".strip() def test_html_output_with_title(self, helper_table: PrettyTable) -> None: helper_table.title = "Title & Title" result = helper_table.get_html_string( attributes={"bgcolor": "red", "a Title & Title @@ -140,16 +132,13 @@ class TestHtmlOutput: """.strip() - ) def test_html_output_formatted_with_title(self, helper_table: PrettyTable) -> None: helper_table.title = "Title & Title" result = helper_table.get_html_string( attributes={"bgcolor": "red", "a Title & Title @@ -181,8 +170,7 @@ class TestHtmlOutput: -""".strip() # noqa: E501 - ) +""".strip() def test_html_output_without_escaped_header( self, empty_helper_table: PrettyTable @@ -194,9 +182,7 @@ class TestHtmlOutput: "Field 3", ] result = empty_helper_table.get_html_string(escape_header=False) - assert ( - result.strip() - == """ + assert result.strip() == """ @@ -210,7 +196,6 @@ class TestHtmlOutput:
""".strip() - ) def test_html_output_without_escaped_data( self, empty_helper_table: PrettyTable @@ -224,9 +209,7 @@ class TestHtmlOutput: ] ) result = empty_helper_table.get_html_string(escape_data=False) - assert ( - result.strip() - == """ + assert result.strip() == """ @@ -246,7 +229,6 @@ class TestHtmlOutput:
""".strip() - ) def test_html_output_with_escaped_header( self, empty_helper_table: PrettyTable @@ -258,9 +240,7 @@ class TestHtmlOutput: "Field 3", ] result = empty_helper_table.get_html_string(escape_header=True) - assert ( - result.strip() - == """ + assert result.strip() == """ @@ -274,7 +254,6 @@ class TestHtmlOutput:
""".strip() - ) def test_html_output_with_escaped_data( self, empty_helper_table: PrettyTable @@ -288,9 +267,7 @@ class TestHtmlOutput: ] ) result = empty_helper_table.get_html_string(escape_data=True) - assert ( - result.strip() - == """ + assert result.strip() == """ @@ -309,8 +286,7 @@ class TestHtmlOutput:
-""".strip() # noqa: E501 - ) +""".strip() def test_html_output_formatted_without_escaped_header( self, empty_helper_table: PrettyTable @@ -322,9 +298,7 @@ class TestHtmlOutput: "Field 3", ] result = empty_helper_table.get_html_string(escape_header=False, format=True) - assert ( - result.strip() - == """ + assert result.strip() == """ @@ -337,8 +311,7 @@ class TestHtmlOutput:
-""".strip() # noqa: E501 - ) +""".strip() def test_html_output_formatted_without_escaped_data( self, empty_helper_table: PrettyTable @@ -352,9 +325,7 @@ class TestHtmlOutput: ] ) result = empty_helper_table.get_html_string(escape_data=False, format=True) - assert ( - result.strip() - == """ + assert result.strip() == """ @@ -373,8 +344,7 @@ class TestHtmlOutput:
-""".strip() # noqa: E501 - ) +""".strip() def test_html_output_formatted_with_escaped_header( self, empty_helper_table: PrettyTable @@ -386,9 +356,7 @@ class TestHtmlOutput: "Field 3", ] result = empty_helper_table.get_html_string(escape_header=True, format=True) - assert ( - result.strip() - == """ + assert result.strip() == """ @@ -401,8 +369,7 @@ class TestHtmlOutput:
-""".strip() # noqa: E501 - ) +""".strip() def test_html_output_formatted_with_escaped_data( self, empty_helper_table: PrettyTable @@ -416,9 +383,7 @@ class TestHtmlOutput: ] ) result = empty_helper_table.get_html_string(escape_data=True, format=True) - assert ( - result.strip() - == """ + assert result.strip() == """ @@ -437,8 +402,47 @@ class TestHtmlOutput:
-""".strip() # noqa: E501 - ) +""".strip() + + def test_escape_ctor(self) -> None: + table = PrettyTable(["<>", "&"], escape_data=False, escape_header=False) + table.add_row(["<>", "&"]) + assert table.get_html_string().strip() == """ + + + + + + + + + + + + + +
<>&
<>&
""".strip() + + def test_escape_setter(self) -> None: + table = PrettyTable(["<>", "&"]) + table.escape_data = False + table.escape_header = False + table.add_row(["<>", "&"]) + assert table.get_html_string().strip() == """ + + + + + + + + + + + + + +
<>&
<>&
""".strip() def test_table_formatted_html_autoindex(self) -> None: """See also #199""" @@ -450,9 +454,7 @@ class TestHtmlOutput: table.format = True table.add_autoindex("I") - assert ( - table.get_html_string().strip() - == """ + assert table.get_html_string().strip() == """ @@ -482,17 +484,14 @@ class TestHtmlOutput: -
value 207
""".strip() # noqa: E501 - ) +""".strip() def test_internal_border_preserved_html(self, helper_table: PrettyTable) -> None: helper_table.format = True helper_table.border = False helper_table.preserve_internal_border = True - assert ( - helper_table.get_html_string().strip() - == """ + assert helper_table.get_html_string().strip() == """ @@ -523,17 +522,14 @@ class TestHtmlOutput:
-""".strip() # noqa: E501 - ) +""".strip() def test_break_line_html(self) -> None: table = PrettyTable(["Field 1", "Field 2"]) table.add_row(["value 1", "value2\nsecond line"]) table.add_row(["value 3", "value4"]) result = table.get_html_string(hrules=HRuleStyle.ALL) - assert ( - result.strip() - == """ + assert result.strip() == """ @@ -553,16 +549,13 @@ class TestHtmlOutput:
""".strip() - ) def test_break_line_xhtml(self) -> None: table = PrettyTable(["Field 1", "Field 2"]) table.add_row(["value 1", "value2\nsecond line"]) table.add_row(["value 3", "value4"]) result = table.get_html_string(hrules=HRuleStyle.ALL, xhtml=True) - assert ( - result.strip() - == """ + assert result.strip() == """ @@ -582,4 +575,3 @@ class TestHtmlOutput:
""".strip() - ) diff --git a/contrib/python/prettytable/py3/tests/test_json.py b/contrib/python/prettytable/py3/tests/test_json.py index 0e2b3659397..c04b72c2876 100644 --- a/contrib/python/prettytable/py3/tests/test_json.py +++ b/contrib/python/prettytable/py3/tests/test_json.py @@ -6,9 +6,7 @@ from prettytable import PrettyTable, from_json class TestJSONOutput: def test_json_output(self, helper_table: PrettyTable) -> None: result = helper_table.get_json_string() - assert ( - result.strip() - == """ + assert result.strip() == """ [ [ "", @@ -35,12 +33,9 @@ class TestJSONOutput: "Field 3": "value9" } ]""".strip() - ) options = {"fields": ["Field 1", "Field 3"]} result = helper_table.get_json_string(**options) - assert ( - result.strip() - == """ + assert result.strip() == """ [ [ "Field 1", @@ -59,7 +54,6 @@ class TestJSONOutput: "Field 3": "value9" } ]""".strip() - ) def test_json_output_options(self, helper_table: PrettyTable) -> None: result = helper_table.get_json_string( diff --git a/contrib/python/prettytable/py3/tests/test_mediawiki.py b/contrib/python/prettytable/py3/tests/test_mediawiki.py index 7684b617324..f472440a53a 100644 --- a/contrib/python/prettytable/py3/tests/test_mediawiki.py +++ b/contrib/python/prettytable/py3/tests/test_mediawiki.py @@ -7,9 +7,7 @@ from prettytable import PrettyTable, from_mediawiki class TestMediaWikiOutput: def test_mediawiki_output(self, helper_table: PrettyTable) -> None: - assert ( - helper_table.get_mediawiki_string(header=True).strip() - == """ + assert helper_table.get_mediawiki_string(header=True).strip() == """ {| class="wikitable" |- ! !! Field 1 !! Field 2 !! Field 3 @@ -21,12 +19,9 @@ class TestMediaWikiOutput: | 7 || value 7 || value8 || value9 |} """.strip() - ) def test_mediawiki_output_without_header(self, helper_table: PrettyTable) -> None: - assert ( - helper_table.get_mediawiki_string(header=False).strip() - == """ + assert helper_table.get_mediawiki_string(header=False).strip() == """ {| class="wikitable" |- | 1 || value 1 || value2 || value3 @@ -36,7 +31,6 @@ class TestMediaWikiOutput: | 7 || value 7 || value8 || value9 |} """.strip() - ) def test_mediawiki_output_with_caption(self, helper_table: PrettyTable) -> None: assert ( diff --git a/contrib/python/prettytable/py3/tests/test_prettytable.py b/contrib/python/prettytable/py3/tests/test_prettytable.py index 7d85fad86f1..01bf4ac0cb5 100644 --- a/contrib/python/prettytable/py3/tests/test_prettytable.py +++ b/contrib/python/prettytable/py3/tests/test_prettytable.py @@ -52,107 +52,112 @@ class TestNoneOption: def test_no_value_replace_none(self) -> None: table = PrettyTable(["Field 1", "Field 2", "Field 3"]) table.add_row(["value 1", None, "value 2"]) - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +---------+---------+---------+ | Field 1 | Field 2 | Field 3 | +---------+---------+---------+ | value 1 | None | value 2 | +---------+---------+---------+ """.strip() - ) def test_no_value_replace_none_with_default_field_names(self) -> None: table = PrettyTable() table.add_row(["value 1", "None", "value 2"]) - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +---------+---------+---------+ | Field 1 | Field 2 | Field 3 | +---------+---------+---------+ | value 1 | None | value 2 | +---------+---------+---------+ """.strip() - ) def test_replace_none_all(self) -> None: table = PrettyTable( ["Field 1", "Field 2", "Field 3", "Field 4"], none_format="N/A" ) table.add_row(["value 1", None, "None", ""]) - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +---------+---------+---------+---------+ | Field 1 | Field 2 | Field 3 | Field 4 | +---------+---------+---------+---------+ | value 1 | N/A | N/A | | +---------+---------+---------+---------+ """.strip() - ) def test_replace_none_by_col(self) -> None: table = PrettyTable(["Field 1", "Field 2", "Field 3"]) table.none_format["Field 2"] = "N/A" table.none_format["Field 3"] = "" table.add_row(["value 1", None, None]) - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +---------+---------+---------+ | Field 1 | Field 2 | Field 3 | +---------+---------+---------+ | value 1 | N/A | | +---------+---------+---------+ """.strip() - ) def test_replace_none_recompute_width(self) -> None: table = PrettyTable() table.add_row([None]) table.none_format = "0123456789" - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +------------+ | Field 1 | +------------+ | 0123456789 | +------------+ """.strip() - ) def test_replace_none_maintain_width_on_recompute(self) -> None: table = PrettyTable() table.add_row(["Hello"]) table.none_format = "0123456789" - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +---------+ | Field 1 | +---------+ | Hello | +---------+ """.strip() - ) def test_replace_none_recompute_width_multi_column(self) -> None: table = PrettyTable() table.add_row(["Hello", None, "World"]) table.none_format = "0123456789" - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ ++---------+------------+---------+ +| Field 1 | Field 2 | Field 3 | ++---------+------------+---------+ +| Hello | 0123456789 | World | ++---------+------------+---------+ +""".strip() + + def test_replace_custom_format_with_none(self) -> None: + table = PrettyTable() + table.custom_format = {"Field 2": lambda f, v: f"'{v}'"} + table.add_row(["Hello", None, "World"]) + table.none_format = "0123456789" + assert table.get_string().strip() == """ ++---------+------------+---------+ +| Field 1 | Field 2 | Field 3 | ++---------+------------+---------+ +| Hello | 0123456789 | World | ++---------+------------+---------+ +""".strip() + + def test_replace_custom_format_with_none_dict(self) -> None: + table = PrettyTable() + table.custom_format = {"Field 2": lambda f, v: f"'{v}'"} + table.add_row(["Hello", None, "World"]) + table.none_format = {"Field 2": "0123456789"} + assert table.get_string().strip() == """ +---------+------------+---------+ | Field 1 | Field 2 | Field 3 | +---------+------------+---------+ | Hello | 0123456789 | World | +---------+------------+---------+ """.strip() - ) class TestBuildEquivalence: @@ -361,6 +366,50 @@ class TestAlignment: header=True ) == aligned_after_table.get_mediawiki_string(header=True) + def test_aligned_one_column(self, city_data: PrettyTable) -> None: + city_data.align["Population"] = "r" + assert city_data.get_string() == """ ++-----------+------+------------+-----------------+ +| City name | Area | Population | Annual Rainfall | ++-----------+------+------------+-----------------+ +| Adelaide | 1295 | 1158259 | 600.5 | +| Brisbane | 5905 | 1857594 | 1146.4 | +| Darwin | 112 | 120900 | 1714.7 | +| Hobart | 1357 | 205556 | 619.5 | +| Sydney | 2058 | 4336374 | 1214.8 | +| Melbourne | 1566 | 3806092 | 646.9 | +| Perth | 5386 | 1554769 | 869.4 | ++-----------+------+------------+-----------------+""".strip() + + def test_aligned_one_column_invalid(self, city_data: PrettyTable) -> None: + with pytest.raises(ValueError): + city_data.align["Population"] = "unexpected" # type: ignore[assignment] + + def test_aligned_one_column_invalid_dict(self, city_data: PrettyTable) -> None: + with pytest.raises(ValueError): + city_data.align = {"Population": "unexpected"} # type: ignore[dict-item] + + def test_rename_drops_stale_align_keys(self) -> None: + table = PrettyTable() + table.field_names = ["a", "b", "c"] + table.add_row([1, 2, 3]) + table.align["a"] = "l" + table.field_names = ["x", "y", "z"] + assert set(table.field_names) == {"x", "y", "z"} + for old in ("a", "b", "c"): + assert old not in table._align + assert old not in table._valign + assert table.align["x"] == "l" + + def test_rename_keeps_overlapping_align_keys(self) -> None: + table = PrettyTable() + table.field_names = ["a", "b", "c"] + table.add_row([1, 2, 3]) + table.field_names = ["x", "b", "z"] + assert "b" in table._align + assert "a" not in table._align + assert "c" not in table._align + class TestOptionOverride: """Make sure all options are properly overwritten by get_string.""" @@ -415,11 +464,42 @@ class TestOptionAttribute: assert city_data.get_string() == city_data[:].get_string() def test_set_for_one_column(self, city_data: PrettyTable) -> None: - city_data.align["Rainfall"] = "l" - city_data.max_width["Name"] = 10 - city_data.int_format["Population"] = "4" - city_data.float_format["Area"] = "2.2" - assert city_data.get_string() == city_data[:].get_string() + city_data.min_width["City name"] = 15 + city_data.int_format["Area"] = "6" + city_data.float_format["Annual Rainfall"] = "6.2" + city_data.int_format["Population"] = "10" + city_data.align["Annual Rainfall"] = "l" + assert city_data.get_string() == """ ++-----------------+--------+------------+-----------------+ +| City name | Area | Population | Annual Rainfall | ++-----------------+--------+------------+-----------------+ +| Adelaide | 1295 | 1158259 | 600.50 | +| Brisbane | 5905 | 1857594 | 1146.40 | +| Darwin | 112 | 120900 | 1714.70 | +| Hobart | 1357 | 205556 | 619.50 | +| Sydney | 2058 | 4336374 | 1214.80 | +| Melbourne | 1566 | 3806092 | 646.90 | +| Perth | 5386 | 1554769 | 869.40 | ++-----------------+--------+------------+-----------------+""".strip() + + def test_set_for_one_column2(self, city_data: PrettyTable) -> None: + city_data.min_width = {"City name": 15} + city_data.int_format = {"Area": "6"} + city_data.float_format = {"Annual Rainfall": "6.2"} + city_data.int_format = {"Population": "10"} + city_data.align = {"Annual Rainfall": "l"} + assert city_data.get_string() == """ ++-----------------+--------+------------+-----------------+ +| City name | Area | Population | Annual Rainfall | ++-----------------+--------+------------+-----------------+ +| Adelaide | 1295 | 1158259 | 600.50 | +| Brisbane | 5905 | 1857594 | 1146.40 | +| Darwin | 112 | 120900 | 1714.70 | +| Hobart | 1357 | 205556 | 619.50 | +| Sydney | 2058 | 4336374 | 1214.80 | +| Melbourne | 1566 | 3806092 | 646.90 | +| Perth | 5386 | 1554769 | 869.40 | ++-----------------+--------+------------+-----------------+""".strip() def test_preserve_internal_border(self) -> None: table = PrettyTable(preserve_internal_border=True) @@ -429,16 +509,13 @@ class TestOptionAttribute: helper_table.border = False helper_table.preserve_internal_border = True - assert ( - helper_table.get_string().strip() - == """ + assert helper_table.get_string().strip() == """ | Field 1 | Field 2 | Field 3 ---+---------+---------+--------- 1 | value 1 | value2 | value3 4 | value 4 | value5 | value6 7 | value 7 | value8 | value9 """.strip() # noqa: W291 - ) @pytest.fixture(scope="module") @@ -481,6 +558,15 @@ class TestBasic: table.add_rows([]) assert str(city_data) == str(table) + def test_add_row_toolong(self) -> None: + table = PrettyTable(CITY_DATA_HEADER) + with pytest.raises(ValueError): + table.add_row(["Geelong", 1, 308915, 123.1, 1]) + + def test_add_column_invalid(self, city_data: PrettyTable) -> None: + with pytest.raises(ValueError): + city_data.add_column("City name", ["Geelong", 2, 3, 4, 5, 6, 7, 8, 9, 10]) + def _test_no_blank_lines(self, table: PrettyTable) -> None: string = table.get_string() lines = string.split("\n") @@ -515,6 +601,33 @@ class TestBasic: city_data.title = "My table (75 characters wide) " + "=" * 45 self._test_all_length_equal(city_data) + def test_multiline_title(self, city_data: PrettyTable) -> None: + """A title with \\n should produce multiple bordered title lines.""" + city_data.title = "Line 1\nLine 2" + assert city_data.get_string() == """ ++-------------------------------------------------+ +| Line 1 | +| Line 2 | ++-----------+------+------------+-----------------+ +| City name | Area | Population | Annual Rainfall | ++-----------+------+------------+-----------------+ +| Adelaide | 1295 | 1158259 | 600.5 | +| Brisbane | 5905 | 1857594 | 1146.4 | +| Darwin | 112 | 120900 | 1714.7 | +| Hobart | 1357 | 205556 | 619.5 | +| Sydney | 2058 | 4336374 | 1214.8 | +| Melbourne | 1566 | 3806092 | 646.9 | +| Perth | 5386 | 1554769 | 869.4 | ++-----------+------+------------+-----------------+ +""".strip() + + def test_multiline_title_html(self, city_data: PrettyTable) -> None: + """Multiline titles should use
in HTML caption.""" + city_data.title = "Line 1\nLine 2" + html = city_data.get_html_string() + assert "
" in html + assert "" in html + def test_no_blank_lines_without_border(self, city_data: PrettyTable) -> None: """No table should ever have blank lines in it.""" city_data.border = False @@ -577,12 +690,12 @@ class TestBasic: def test_no_blank_lines_with_float_format(self, city_data: PrettyTable) -> None: """No table should ever have blank lines in it.""" - city_data.float_format = "6.2f" + city_data.float_format = "6.2" self._test_no_blank_lines(city_data) def test_all_lengths_equal_with_float_format(self, city_data: PrettyTable) -> None: """All lines in a table should be of the same length.""" - city_data.float_format = "6.2f" + city_data.float_format = "6.2" self._test_all_length_equal(city_data) def test_no_blank_lines_from_csv(self, city_data_from_csv: PrettyTable) -> None: @@ -611,6 +724,10 @@ class TestBasic: def test_colcount(self, city_data: PrettyTable) -> None: assert city_data.colcount == 4 + def test_colcount_empty(self) -> None: + table = PrettyTable() + assert table.colcount == 0 + def test_getitem(self, city_data: PrettyTable) -> None: assert ( city_data[1].get_string() @@ -625,6 +742,83 @@ class TestBasic: with pytest.raises(IndexError): assert city_data[10] + def test_header_style_upper(self) -> None: + table = PrettyTable(header_style="upper") + table.add_row([1, 2, 3]) + assert table.get_string() == """ ++---------+---------+---------+ +| FIELD 1 | FIELD 2 | FIELD 3 | ++---------+---------+---------+ +| 1 | 2 | 3 | ++---------+---------+---------+ +""".strip() + + def test_header_style_upper_setter(self) -> None: + table = PrettyTable() + table.header_style = "upper" + table.add_row([1, 2, 3]) + assert table.get_string() == """ ++---------+---------+---------+ +| FIELD 1 | FIELD 2 | FIELD 3 | ++---------+---------+---------+ +| 1 | 2 | 3 | ++---------+---------+---------+ +""".strip() + + def test_header_style_cap(self) -> None: + table = PrettyTable(["cOl oNE", "col two", "COL THREE"], header_style="cap") + table.add_row([1, 2, 3]) + assert table.get_string() == """ ++---------+---------+-----------+ +| Col one | Col two | Col three | ++---------+---------+-----------+ +| 1 | 2 | 3 | ++---------+---------+-----------+ +""".strip() + + def test_header_style_title(self) -> None: + table = PrettyTable(["cOl oNE", "col two", "COL THREE"], header_style="title") + table.add_row([1, 2, 3]) + assert table.get_string() == """ ++---------+---------+-----------+ +| Col One | Col Two | Col Three | ++---------+---------+-----------+ +| 1 | 2 | 3 | ++---------+---------+-----------+ +""".strip() + + def test_header_style_lower(self) -> None: + table = PrettyTable(header_style="lower") + table.add_row([1, 2, 3]) + assert table.get_string() == """ ++---------+---------+---------+ +| field 1 | field 2 | field 3 | ++---------+---------+---------+ +| 1 | 2 | 3 | ++---------+---------+---------+ +""".strip() + + def test_horizontal_align_char_setter(self) -> None: + table = PrettyTable() + table.horizontal_align_char = "!" + table.add_row([1, 2, 3]) + assert table.get_string() == """ ++ !-----! + !-----! + !-----! + +| Field 1 | Field 2 | Field 3 | ++ !-----! + !-----! + !-----! + +| 1 | 2 | 3 | ++ !-----! + !-----! + !-----! + +""".strip() + + def test_horizontal_align_char_getter(self) -> None: + table = PrettyTable() + table.horizontal_align_char = ":" + assert table.horizontal_align_char == ":" + + def test_header_style_invalid(self) -> None: + with pytest.raises(ValueError): + PrettyTable(header_style="FooBar") + @pytest.mark.usefixtures("init_db") def test_no_blank_lines_from_db(self, db_cursor: sqlite3.Cursor) -> None: """No table should ever have blank lines in it.""" @@ -641,10 +835,38 @@ class TestBasic: assert table is not None self._test_all_length_equal(table) + def test_border(self) -> None: + table = PrettyTable(border=False) + table.add_row([1, 2, "3"]) + assert table.get_string().strip() == """ +Field 1 Field 2 Field 3 + 1 2 3 """.strip() # noqa: W291 + + def test_reversesort(self) -> None: + table = PrettyTable(["A", "B", "C"], sortby="A", reversesort=True) + table.add_row([1, 2, "3"]) + table.add_row([3, 4, "5"]) + assert table.get_string().strip() == """ ++---+---+---+ +| A | B | C | ++---+---+---+ +| 3 | 4 | 5 | +| 1 | 2 | 3 | ++---+---+---+""".strip() + class TestEmptyTable: """Make sure the print_empty option works""" + def test_print_empty(self) -> None: + table = PrettyTable(print_empty=False) + assert table.get_string().strip() == "" + + def test_print_empty_setter(self) -> None: + table = PrettyTable() + table.print_empty = False + assert table.get_string().strip() == "" + def test_print_empty_true(self, city_data: PrettyTable) -> None: table = PrettyTable() table.field_names = CITY_DATA_HEADER @@ -742,12 +964,24 @@ def float_pt() -> PrettyTable: class TestFloatFormat: + def test_empty(self, float_pt: PrettyTable) -> None: + float_pt.float_format = "" + assert float_pt.get_string() == """ ++----------+----------+ +| Constant | Value | ++----------+----------+ +| Pi | 3.141593 | +| e | 2.718282 | +| sqrt(2) | 1.414214 | ++----------+----------+ +""".strip() + def test_no_decimals(self, float_pt: PrettyTable) -> None: - float_pt.float_format = ".0f" + float_pt.float_format = ".0" assert "." not in float_pt.get_string() def test_round_to_5dp(self, float_pt: PrettyTable) -> None: - float_pt.float_format = ".5f" + float_pt.float_format = ".5" string = float_pt.get_string() assert "3.14159" in string assert "3.141592" not in string @@ -758,7 +992,7 @@ class TestFloatFormat: assert "1.414213" not in string def test_pad_with_2zeroes(self, float_pt: PrettyTable) -> None: - float_pt.float_format = "06.2f" + float_pt.float_format = "06.2" string = float_pt.get_string() assert "003.14" in string assert "002.72" in string @@ -768,9 +1002,7 @@ class TestFloatFormat: class TestColumnFormattingfromDict: def test_set_align_format(self, city_data: PrettyTable) -> None: city_data.align = {"Annual Rainfall": "r"} - assert ( - city_data.get_string() - == """ + assert city_data.get_string() == """ +-----------+------+------------+-----------------+ | City name | Area | Population | Annual Rainfall | +-----------+------+------------+-----------------+ @@ -783,7 +1015,6 @@ class TestColumnFormattingfromDict: | Perth | 5386 | 1554769 | 869.4 | +-----------+------+------------+-----------------+ """.strip() - ) def test_set_valign_format(self, city_data: PrettyTable) -> None: table = PrettyTable( @@ -802,9 +1033,7 @@ class TestColumnFormattingfromDict: ] ) - assert ( - table.get_string() - == """ + assert table.get_string() == """ +---------+----------------------+---------+------------+---------+------------+ | Field 1 | Field 2 | Field 3 | Field 4 | Field 5 | Field 6 | +---------+----------------------+---------+------------+---------+------------+ @@ -818,7 +1047,6 @@ class TestColumnFormattingfromDict: | | | | diam | | diam | +---------+----------------------+---------+------------+---------+------------+ """.strip() - ) def test_max_width( self, @@ -838,9 +1066,7 @@ class TestColumnFormattingfromDict: ] ) - assert ( - table.get_string() - == """ + assert table.get_string() == """ +---------+----------------------+---------+------------+---------+------------+ | Field 1 | Field 2 | Field 3 | Field 4 | Field 5 | Field 6 | +---------+----------------------+---------+------------+---------+------------+ @@ -854,7 +1080,6 @@ class TestColumnFormattingfromDict: | | | | diam | | diam | +---------+----------------------+---------+------------+---------+------------+ """.strip() - ) def test_min_width(self, city_data: PrettyTable) -> None: city_data.min_width = { @@ -863,9 +1088,7 @@ class TestColumnFormattingfromDict: "Population": 20, "Annual Rainfall": 20, } - assert ( - city_data.get_string() - == """ + assert city_data.get_string() == """ +----------------------+------------+----------------------+----------------------+ | City name | Area | Population | Annual Rainfall | +----------------------+------------+----------------------+----------------------+ @@ -878,13 +1101,27 @@ class TestColumnFormattingfromDict: | Perth | 5386 | 1554769 | 869.4 | +----------------------+------------+----------------------+----------------------+ """.strip() - ) def test_set_int_format(self, city_data: PrettyTable) -> None: city_data.int_format = {"Population": "20"} - assert ( - city_data.get_string() - == """ + assert city_data.get_string() == """ ++-----------+------+----------------------+-----------------+ +| City name | Area | Population | Annual Rainfall | ++-----------+------+----------------------+-----------------+ +| Adelaide | 1295 | 1158259 | 600.5 | +| Brisbane | 5905 | 1857594 | 1146.4 | +| Darwin | 112 | 120900 | 1714.7 | +| Hobart | 1357 | 205556 | 619.5 | +| Sydney | 2058 | 4336374 | 1214.8 | +| Melbourne | 1566 | 3806092 | 646.9 | +| Perth | 5386 | 1554769 | 869.4 | ++-----------+------+----------------------+-----------------+ +""".strip() + + def test_set_int_format_overwrite_dict(self, city_data: PrettyTable) -> None: + city_data.custom_format = {"Population": lambda f, v: f"'{v}'"} + city_data.int_format = {"Population": "20"} + assert city_data.get_string() == """ +-----------+------+----------------------+-----------------+ | City name | Area | Population | Annual Rainfall | +-----------+------+----------------------+-----------------+ @@ -897,13 +1134,60 @@ class TestColumnFormattingfromDict: | Perth | 5386 | 1554769 | 869.4 | +-----------+------+----------------------+-----------------+ """.strip() - ) + + def test_set_int_format_overwrite(self, city_data: PrettyTable) -> None: + city_data.custom_format = {"Population": lambda f, v: f"'{v}'"} + city_data.int_format = "20" + assert city_data.get_string() == """ ++-----------+----------------------+----------------------+-----------------+ +| City name | Area | Population | Annual Rainfall | ++-----------+----------------------+----------------------+-----------------+ +| Adelaide | 1295 | 1158259 | 600.5 | +| Brisbane | 5905 | 1857594 | 1146.4 | +| Darwin | 112 | 120900 | 1714.7 | +| Hobart | 1357 | 205556 | 619.5 | +| Sydney | 2058 | 4336374 | 1214.8 | +| Melbourne | 1566 | 3806092 | 646.9 | +| Perth | 5386 | 1554769 | 869.4 | ++-----------+----------------------+----------------------+-----------------+""".strip() def test_set_float_format(self, city_data: PrettyTable) -> None: city_data.float_format = {"Annual Rainfall": "4.2"} - assert ( - city_data.get_string() - == """ + assert city_data.get_string() == """ ++-----------+------+------------+-----------------+ +| City name | Area | Population | Annual Rainfall | ++-----------+------+------------+-----------------+ +| Adelaide | 1295 | 1158259 | 600.50 | +| Brisbane | 5905 | 1857594 | 1146.40 | +| Darwin | 112 | 120900 | 1714.70 | +| Hobart | 1357 | 205556 | 619.50 | +| Sydney | 2058 | 4336374 | 1214.80 | +| Melbourne | 1566 | 3806092 | 646.90 | +| Perth | 5386 | 1554769 | 869.40 | ++-----------+------+------------+-----------------+ +""".strip() + + def test_set_float_format_overwrite_dict(self, city_data: PrettyTable) -> None: + city_data.custom_format = {"Annual Rainfall": lambda f, v: f"'{v}'"} + city_data.float_format = {"Annual Rainfall": "4.2"} + assert city_data.get_string() == """ ++-----------+------+------------+-----------------+ +| City name | Area | Population | Annual Rainfall | ++-----------+------+------------+-----------------+ +| Adelaide | 1295 | 1158259 | 600.50 | +| Brisbane | 5905 | 1857594 | 1146.40 | +| Darwin | 112 | 120900 | 1714.70 | +| Hobart | 1357 | 205556 | 619.50 | +| Sydney | 2058 | 4336374 | 1214.80 | +| Melbourne | 1566 | 3806092 | 646.90 | +| Perth | 5386 | 1554769 | 869.40 | ++-----------+------+------------+-----------------+ +""".strip() + + def test_set_float_format_overwrite(self, city_data: PrettyTable) -> None: + city_data.custom_format = {"Annual Rainfall": lambda f, v: f"'{v}'"} + city_data.float_format = "4.2" + assert city_data.get_string() == """ +-----------+------+------------+-----------------+ | City name | Area | Population | Annual Rainfall | +-----------+------+------------+-----------------+ @@ -916,13 +1200,10 @@ class TestColumnFormattingfromDict: | Perth | 5386 | 1554769 | 869.40 | +-----------+------+------------+-----------------+ """.strip() - ) def test_set_custom_format(self, city_data: PrettyTable) -> None: city_data.custom_format = {"Annual Rainfall": lambda f, v: f"{v:.2f}"} - assert ( - city_data.get_string() - == """ + assert city_data.get_string() == """ +-----------+------+------------+-----------------+ | City name | Area | Population | Annual Rainfall | +-----------+------+------------+-----------------+ @@ -935,22 +1216,18 @@ class TestColumnFormattingfromDict: | Perth | 5386 | 1554769 | 869.40 | +-----------+------+------------+-----------------+ """.strip() - ) def test_set_none_format(self, city_data: PrettyTable) -> None: city_data.clear_rows() city_data.add_row([None, None, None, None]) city_data.none_format = {"Annual Rainfall": "N/A"} - assert ( - city_data.get_string() - == """ + assert city_data.get_string() == """ +-----------+------+------------+-----------------+ | City name | Area | Population | Annual Rainfall | +-----------+------+------------+-----------------+ | None | None | None | N/A | +-----------+------+------------+-----------------+ """.strip() - ) class TestBreakLine: @@ -1050,6 +1327,22 @@ class TestCsvOutput: "value 7,value9\r\n" ) + def test_csv_formatting(self) -> None: + table = PrettyTable() + table.add_column("Name", ["Alice", "Bob", "Charlie"]) + table.add_column("int_format", [7, 0, -42]) + table.add_column("float_format", [3.14159, 10.0, -2.71828]) + table.add_column("custom_format", [1234, 0, -5678]) + table.int_format["int_format"] = "05" + table.float_format["float_format"] = ".1" + table.custom_format["custom_format"] = lambda f, v: f"{v:,}" + assert table.get_csv_string() == ( + "Name,int_format,float_format,custom_format\r\n" + 'Alice,00007,3.1,"1,234"\r\n' + "Bob,00000,10.0,0\r\n" + 'Charlie,-0042,-2.7,"-5,678"\r\n' + ) + def test_paginate(city_data: PrettyTable) -> None: expected_page_1 = """ @@ -1168,9 +1461,7 @@ class TestCustomFormatter: def test_use_custom_formatter_for_int(self, city_data: PrettyTable) -> None: city_data.custom_format["Annual Rainfall"] = lambda n, v: f"{v:.2f}" - assert ( - city_data.get_string().strip() - == """ + assert city_data.get_string().strip() == """ +-----------+------+------------+-----------------+ | City name | Area | Population | Annual Rainfall | +-----------+------+------------+-----------------+ @@ -1183,7 +1474,6 @@ class TestCustomFormatter: | Perth | 5386 | 1554769 | 869.40 | +-----------+------+------------+-----------------+ """.strip() - ) def test_custom_format_multi_type(self) -> None: table = PrettyTable(["col_date", "col_str", "col_float", "col_int"]) @@ -1192,9 +1482,7 @@ class TestCustomFormatter: table.custom_format["col_date"] = lambda f, v: v.strftime("%d %b %Y") table.custom_format["col_float"] = lambda f, v: f"{v:.3f}" table.custom_format["col_int"] = lambda f, v: f"{v:,}" - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +-------------+----------+-----------+------------+ | col_date | col_str | col_float | col_int | +-------------+----------+-----------+------------+ @@ -1202,7 +1490,6 @@ class TestCustomFormatter: | 01 Feb 2021 | February | 54321.123 | 87,654,321 | +-------------+----------+-----------+------------+ """.strip() - ) def test_custom_format_multi_type_using_on_function(self) -> None: table = PrettyTable(["col_date", "col_str", "col_float", "col_int"]) @@ -1219,9 +1506,7 @@ class TestCustomFormatter: return str(value) table.custom_format = my_format - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +-------------+----------+-----------+------------+ | col_date | col_str | col_float | col_int | +-------------+----------+-----------+------------+ @@ -1229,7 +1514,41 @@ class TestCustomFormatter: | 01 Feb 2021 | February | 54321.123 | 87,654,321 | +-------------+----------+-----------+------------+ """.strip() - ) + + def test_overwrite_default_format(self, city_data: PrettyTable) -> None: + city_data.float_format = "6.2" + city_data.int_format = "10" + city_data.custom_format["Annual Rainfall"] = lambda f, v: f"+++{v:.4f}+++" + assert city_data.get_string() == """ ++-----------+------------+------------+-----------------+ +| City name | Area | Population | Annual Rainfall | ++-----------+------------+------------+-----------------+ +| Adelaide | 1295 | 1158259 | +++600.5000+++ | +| Brisbane | 5905 | 1857594 | +++1146.4000+++ | +| Darwin | 112 | 120900 | +++1714.7000+++ | +| Hobart | 1357 | 205556 | +++619.5000+++ | +| Sydney | 2058 | 4336374 | +++1214.8000+++ | +| Melbourne | 1566 | 3806092 | +++646.9000+++ | +| Perth | 5386 | 1554769 | +++869.4000+++ | ++-----------+------------+------------+-----------------+""".strip() + + def test_overwrite_default_format_dict(self, city_data: PrettyTable) -> None: + city_data.none_format = "N/A" + city_data.float_format = "6.2" + city_data.int_format = "10" + city_data.custom_format = {"Annual Rainfall": lambda f, v: f"+++{v:.4f}+++"} + assert city_data.get_string() == """ ++-----------+------------+------------+-----------------+ +| City name | Area | Population | Annual Rainfall | ++-----------+------------+------------+-----------------+ +| Adelaide | 1295 | 1158259 | +++600.5000+++ | +| Brisbane | 5905 | 1857594 | +++1146.4000+++ | +| Darwin | 112 | 120900 | +++1714.7000+++ | +| Hobart | 1357 | 205556 | +++619.5000+++ | +| Sydney | 2058 | 4336374 | +++1214.8000+++ | +| Melbourne | 1566 | 3806092 | +++646.9000+++ | +| Perth | 5386 | 1554769 | +++869.4000+++ | ++-----------+------------+------------+-----------------+""".strip() class TestRepr: @@ -1289,9 +1608,7 @@ class TestWidth: table.add_row([self.colored, self.colored]) table.add_row(["nothing", "neither"]) result = table.get_string() - assert ( - result.strip() - == f""" + assert result.strip() == f""" +---------+---------+ | Field 1 | Field 2 | +---------+---------+ @@ -1299,16 +1616,13 @@ class TestWidth: | nothing | neither | +---------+---------+ """.strip() - ) def test_reset(self) -> None: table = PrettyTable(["Field 1", "Field 2"]) table.add_row(["abc def\033(B", "\033[31mabc def\033[m"]) table.add_row(["nothing", "neither"]) result = table.get_string() - assert ( - result.strip() - == """ + assert result.strip() == """ +---------+---------+ | Field 1 | Field 2 | +---------+---------+ @@ -1316,7 +1630,15 @@ class TestWidth: | nothing | neither | +---------+---------+ """.strip() - ) + + def test_tab_expansion_aligns_columns(self) -> None: + """Ensure expandtabs() is used in PrettyTable._format_value().""" + table = PrettyTable(["code", "note"]) + table.add_row(["if x:\n\treturn 1", "tab-indented"]) + result = table.get_string() + assert "\t" not in result + line_widths = {len(line) for line in result.splitlines()} + assert len(line_widths) == 1 @pytest.mark.parametrize( "loops, fields, desired_width, border, internal_border", @@ -1380,16 +1702,13 @@ class TestWidth: table.add_row([0]) # FIXME: Table is wider than table.max_table_width - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +----+ | Fi | +----+ | 0 | +----+ """.strip() - ) def test_max_table_width_wide(self) -> None: table = PrettyTable() @@ -1407,9 +1726,7 @@ class TestWidth: ] ) - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +---+---+---+---+---+------------------------------+ | F | F | F | F | F | Field 6 | +---+---+---+---+---+------------------------------+ @@ -1420,7 +1737,6 @@ class TestWidth: | | | | | | dolore magna aliquyam erat, | | | | | | | sed diam voluptua | +---+---+---+---+---+------------------------------+""".strip() - ) def test_max_table_width_wide2(self) -> None: table = PrettyTable() @@ -1436,9 +1752,7 @@ class TestWidth: ] ) - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +---+-----------------+---+-----------------+---+-----------------+ | F | Field 2 | F | Field 4 | F | Field 6 | +---+-----------------+---+-----------------+---+-----------------+ @@ -1448,7 +1762,6 @@ class TestWidth: | e | sadipscing | u | sadipscing | o | sadipscing | | m | elitr, sed diam | m | elitr, sed diam | r | elitr, sed diam | +---+-----------------+---+-----------------+---+-----------------+""".strip() - ) @pytest.mark.parametrize("set_width_parameter", [True, False]) def test_table_max_width_wo_header_width(self, set_width_parameter: bool) -> None: @@ -1496,9 +1809,7 @@ class TestWidth: ] ) - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +---------+------------+---------+------------+---------+------------+ | Field 1 | Field 2 | Field 3 | Field 4 | Field 5 | Field 6 | +---------+------------+---------+------------+---------+------------+ @@ -1511,7 +1822,6 @@ class TestWidth: | | elitr, sed | | elitr, sed | | elitr, sed | | | diam | | diam | | diam | +---------+------------+---------+------------+---------+------------+""".strip() - ) def test_table_width_on_init_with_columns(self) -> None: """See also #272""" @@ -1530,9 +1840,7 @@ class TestWidth: ] ) - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +---------+------------+---------+------------+---------+------------+ | Field 1 | Field 2 | Field 3 | Field 4 | Field 5 | Field 6 | +---------+------------+---------+------------+---------+------------+ @@ -1545,7 +1853,6 @@ class TestWidth: | | elitr, sed | | elitr, sed | | elitr, sed | | | diam | | diam | | diam | +---------+------------+---------+------------+---------+------------+""".strip() - ) def test_table_minwidth_on_init_with_columns(self) -> None: table = PrettyTable(["Field 1", "Field 2"], min_width=20) @@ -1562,7 +1869,7 @@ class TestWidth: | Field 1 | Field 2 | +----------------------+--------------------------------------------------------------------+ | Lorem | Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam | -+----------------------+--------------------------------------------------------------------+""" # noqa: E501 ++----------------------+--------------------------------------------------------------------+""" ) def test_table_min_max_width_on_init_with_columns(self) -> None: @@ -1590,15 +1897,12 @@ class TestWidth: table.field_names = ["Metric", "Initial sol.", "Best sol."] table.add_rows([["foo", 1.0 / 3.0, 1.0 / 3.0]]) - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +--------+--------------+------------+ | Metric | Initial sol. | Best sol. | +--------+--------------+------------+ | foo | 0.33 | 0.33 | +--------+--------------+------------+""".strip() - ) def test_max_table_width_wide_vrules_frame(self) -> None: table = PrettyTable() @@ -1617,9 +1921,7 @@ class TestWidth: ] ) - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ +--------------------------------------------------+ | F F F F F Field 6 | +--------------------------------------------------+ @@ -1630,7 +1932,6 @@ class TestWidth: | dolore magna aliquyam erat, | | sed diam voluptua | +--------------------------------------------------+""".strip() - ) def test_max_table_width_wide_vrules_none(self) -> None: table = PrettyTable() @@ -1649,9 +1950,7 @@ class TestWidth: ] ) - assert ( - table.get_string().strip() - == """ + assert table.get_string().strip() == """ ---------------------------------------------------- F F F F F Field 6 ---------------------------------------------------- @@ -1662,7 +1961,6 @@ class TestWidth: dolore magna aliquyam erat, sed diam voluptua ----------------------------------------------------""".strip() # noqa: W291 - ) class TestFields: @@ -1673,8 +1971,7 @@ class TestFields: ) for row in CITY_DATA: table.add_row(row) - assert ( - """+-----------+-----------------+ + assert """+-----------+-----------------+ | City name | Annual Rainfall | +-----------+-----------------+ | Adelaide | 600.5 | @@ -1684,9 +1981,7 @@ class TestFields: | Sydney | 1214.8 | | Melbourne | 646.9 | | Perth | 869.4 | -+-----------+-----------------+""" - == table.get_string().strip() - ) ++-----------+-----------------+""" == table.get_string().strip() def test_fields(self) -> None: table = PrettyTable() @@ -1694,8 +1989,7 @@ class TestFields: table.fields = ["City name", "Annual Rainfall"] for row in CITY_DATA: table.add_row(row) - assert ( - """+-----------+-----------------+ + assert """+-----------+-----------------+ | City name | Annual Rainfall | +-----------+-----------------+ | Adelaide | 600.5 | @@ -1705,9 +1999,7 @@ class TestFields: | Sydney | 1214.8 | | Melbourne | 646.9 | | Perth | 869.4 | -+-----------+-----------------+""" - == table.get_string().strip() - ) ++-----------+-----------------+""" == table.get_string().strip() class TestGeneralOutput: @@ -1815,3 +2107,15 @@ class TestDeprecations: ) -> None: with pytest.deprecated_call(match=f"the '{name}' constant is deprecated"): exec(f"from {module_name} import {name}") + + def test_options_type_deprecation(self) -> None: + with pytest.deprecated_call(match="OptionsType"): + from prettytable.prettytable import OptionsType + + assert OptionsType is not None + + def test_table_handler_deprecation(self) -> None: + with pytest.deprecated_call(match="TableHandler is deprecated"): + from prettytable import TableHandler + + TableHandler() diff --git a/contrib/python/prettytable/py3/tests/test_rst.py b/contrib/python/prettytable/py3/tests/test_rst.py new file mode 100644 index 00000000000..bc4e4e8498c --- /dev/null +++ b/contrib/python/prettytable/py3/tests/test_rst.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from prettytable import PrettyTable, TableStyle + + +def test_rst_output_without_header(helper_table: PrettyTable) -> None: + helper_table.set_style(TableStyle.RST) + assert helper_table.get_string(header=False).strip() == """ ++---+---------+--------+--------+ +| 1 | value 1 | value2 | value3 | ++---+---------+--------+--------+ +| 4 | value 4 | value5 | value6 | ++---+---------+--------+--------+ +| 7 | value 7 | value8 | value9 | ++---+---------+--------+--------+ +""".strip() + + +def test_rst_output_with_fields(helper_table: PrettyTable) -> None: + helper_table.set_style(TableStyle.RST) + assert helper_table.get_string(fields=["Field 1", "Field 3"]).strip() == """ ++---------+---------+ +| Field 1 | Field 3 | ++=========+=========+ +| value 1 | value3 | ++---------+---------+ +| value 4 | value6 | ++---------+---------+ +| value 7 | value9 | ++---------+---------+ +""".strip() + + +def test_rst_header_uses_equals(helper_table: PrettyTable) -> None: + # Arrange + helper_table.set_style(TableStyle.RST) + + # Act + result = helper_table.get_string() + + # Assert + lines = result.splitlines() + # The header separator (line after header row) should use "=" + header_sep = lines[2] + assert "=" in header_sep + assert header_sep.startswith("+") + assert header_sep.endswith("+") + # Data separators should use "-" + data_sep = lines[4] + assert "=" not in data_sep + assert "-" in data_sep + + +def test_rst_style_does_not_leak(helper_table: PrettyTable) -> None: + # Arrange + original = helper_table.get_string() + + # Act + helper_table.set_style(TableStyle.RST) + helper_table.set_style(TableStyle.DEFAULT) + + # Assert + assert helper_table.get_string() == original + + +def test_rst_output_with_multiline_title(helper_table: PrettyTable) -> None: + # Arrange + helper_table.set_style(TableStyle.RST) + # Act + helper_table.title = "Line 1\nLine 2" + # Assert + assert helper_table.get_string() == """ ++---------------------------------+ +| Line 1 | +| Line 2 | ++---+---------+---------+---------+ +| | Field 1 | Field 2 | Field 3 | ++===+=========+=========+=========+ +| 1 | value 1 | value2 | value3 | ++---+---------+---------+---------+ +| 4 | value 4 | value5 | value6 | ++---+---------+---------+---------+ +| 7 | value 7 | value8 | value9 | ++---+---------+---------+---------+ +""".strip() + + +def test_markdown_to_rst_does_not_leak(helper_table: PrettyTable) -> None: + # Arrange + helper_table.set_style(TableStyle.MARKDOWN) + helper_table.set_style(TableStyle.RST) + + # Act + result = helper_table.get_string() + + # Assert: Markdown's ":" alignment char should not appear in RST output + assert ":" not in result diff --git a/contrib/python/prettytable/py3/tests/test_sorting.py b/contrib/python/prettytable/py3/tests/test_sorting.py index 1968c718f40..172e6347450 100644 --- a/contrib/python/prettytable/py3/tests/test_sorting.py +++ b/contrib/python/prettytable/py3/tests/test_sorting.py @@ -34,9 +34,7 @@ class TestSorting: city_data.sortby = "City name" city_data.sort_key = key - assert ( - city_data.get_string().strip() - == """ + assert city_data.get_string().strip() == """ +-----------+------+------------+-----------------+ | City name | Area | Population | Annual Rainfall | +-----------+------+------------+-----------------+ @@ -49,7 +47,6 @@ class TestSorting: | Melbourne | 1566 | 3806092 | 646.9 | +-----------+------+------------+-----------------+ """.strip() - ) def test_sort_key_at_class_declaration(self) -> None: # Test sorting by length of city name @@ -65,8 +62,7 @@ class TestSorting: assert table.sort_key == key for row in CITY_DATA: table.add_row(row) - assert ( - """+-----------+------+------------+-----------------+ + assert """+-----------+------+------------+-----------------+ | City name | Area | Population | Annual Rainfall | +-----------+------+------------+-----------------+ | Perth | 5386 | 1554769 | 869.4 | @@ -76,11 +72,9 @@ class TestSorting: | Adelaide | 1295 | 1158259 | 600.5 | | Brisbane | 5905 | 1857594 | 1146.4 | | Melbourne | 1566 | 3806092 | 646.9 | -+-----------+------+------------+-----------------+""" - == table.get_string().strip() - ) ++-----------+------+------------+-----------------+""" == table.get_string().strip() - def test_sort_slice(self) -> None: + def test_sort_slice_get_string(self) -> None: """Make sure sorting and slicing interact in the expected way""" table = PrettyTable(["Foo"]) for i in range(20, 0, -1): @@ -92,6 +86,44 @@ class TestSorting: assert "10" not in oldstyle assert "20" in oldstyle + def test_sort_slice(self) -> None: + """Make sure sorting and slicing interact in the expected way""" + # Arrange + table_new = PrettyTable(["Foo"]) + table_old = PrettyTable(["Foo"], oldsortslice=True) + for i in range(20, 0, -1): + table_new.add_row([i]) + table_old.add_row([i]) + + # Act + new_style = table_new.get_string(sortby="Foo", end=10) + old_style = table_old.get_string(sortby="Foo", end=10) + + # Assert + assert "10" in new_style + assert "20" not in new_style + assert "10" not in old_style + assert "20" in old_style + + def test_sort_slice_setter(self) -> None: + # Arrange + table_new = PrettyTable(["Foo"]) + table_old = PrettyTable(["Foo"]) + table_old.oldsortslice = True + for i in range(20, 0, -1): + table_new.add_row([i]) + table_old.add_row([i]) + + # Act + new_style = table_new.get_string(sortby="Foo", end=10) + old_style = table_old.get_string(sortby="Foo", end=10) + + # Assert + assert "10" in new_style + assert "20" not in new_style + assert "10" not in old_style + assert "20" in old_style + def test_sortby_at_class_declaration(self) -> None: """ Fix #354 where initialization of a table with sortby fails @@ -103,8 +135,7 @@ class TestSorting: assert table.sortby == "Area" for row in CITY_DATA: table.add_row(row) - assert ( - """+-----------+------+------------+-----------------+ + assert """+-----------+------+------------+-----------------+ | City name | Area | Population | Annual Rainfall | +-----------+------+------------+-----------------+ | Darwin | 112 | 120900 | 1714.7 | @@ -114,6 +145,4 @@ class TestSorting: | Sydney | 2058 | 4336374 | 1214.8 | | Perth | 5386 | 1554769 | 869.4 | | Brisbane | 5905 | 1857594 | 1146.4 | -+-----------+------+------------+-----------------+""" - == table.get_string().strip() - ) ++-----------+------+------------+-----------------+""" == table.get_string().strip() diff --git a/contrib/python/prettytable/py3/tests/test_style.py b/contrib/python/prettytable/py3/tests/test_style.py index 2ae696e4bc3..1887fa0d40e 100644 --- a/contrib/python/prettytable/py3/tests/test_style.py +++ b/contrib/python/prettytable/py3/tests/test_style.py @@ -1,6 +1,8 @@ from __future__ import annotations +import os import random +from typing import Literal import pytest from pytest_lazy_fixtures import lf @@ -8,6 +10,14 @@ from pytest_lazy_fixtures import lf from prettytable import HRuleStyle, PrettyTable, TableStyle, VRuleStyle from prettytable.prettytable import _str_block_width +# these tables don't display well outside of raw dump to a terminal, so they are +# moved to external files, where they may be very easy to visually align, by command: +# +# $ cat tests/data/*.txt +# +import yatest.common as yc +DATA_DIR = os.path.join(os.path.dirname(yc.source_path(__file__)), "data") + class TestPositionalJunctions: """Verify different cases for positional-junction characters""" @@ -15,9 +25,7 @@ class TestPositionalJunctions: def test_default(self, city_data: PrettyTable) -> None: city_data.set_style(TableStyle.DOUBLE_BORDER) - assert ( - city_data.get_string().strip() - == """ + assert city_data.get_string().strip() == """ ╔═══════════╦══════╦════════════╦═════════════════╗ ║ City name ║ Area ║ Population ║ Annual Rainfall ║ ╠═══════════╬══════╬════════════╬═════════════════╣ @@ -29,15 +37,12 @@ class TestPositionalJunctions: ║ Melbourne ║ 1566 ║ 3806092 ║ 646.9 ║ ║ Perth ║ 5386 ║ 1554769 ║ 869.4 ║ ╚═══════════╩══════╩════════════╩═════════════════╝""".strip() - ) def test_no_header(self, city_data: PrettyTable) -> None: city_data.set_style(TableStyle.DOUBLE_BORDER) city_data.header = False - assert ( - city_data.get_string().strip() - == """ + assert city_data.get_string().strip() == """ ╔═══════════╦══════╦═════════╦════════╗ ║ Adelaide ║ 1295 ║ 1158259 ║ 600.5 ║ ║ Brisbane ║ 5905 ║ 1857594 ║ 1146.4 ║ @@ -47,15 +52,12 @@ class TestPositionalJunctions: ║ Melbourne ║ 1566 ║ 3806092 ║ 646.9 ║ ║ Perth ║ 5386 ║ 1554769 ║ 869.4 ║ ╚═══════════╩══════╩═════════╩════════╝""".strip() - ) def test_with_title(self, city_data: PrettyTable) -> None: city_data.set_style(TableStyle.DOUBLE_BORDER) city_data.title = "Title" - assert ( - city_data.get_string().strip() - == """ + assert city_data.get_string().strip() == """ ╔═════════════════════════════════════════════════╗ ║ Title ║ ╠═══════════╦══════╦════════════╦═════════════════╣ @@ -69,15 +71,12 @@ class TestPositionalJunctions: ║ Melbourne ║ 1566 ║ 3806092 ║ 646.9 ║ ║ Perth ║ 5386 ║ 1554769 ║ 869.4 ║ ╚═══════════╩══════╩════════════╩═════════════════╝""".strip() - ) def test_with_title_no_header(self, city_data: PrettyTable) -> None: city_data.set_style(TableStyle.DOUBLE_BORDER) city_data.title = "Title" city_data.header = False - assert ( - city_data.get_string().strip() - == """ + assert city_data.get_string().strip() == """ ╔═════════════════════════════════════╗ ║ Title ║ ╠═══════════╦══════╦═════════╦════════╣ @@ -89,15 +88,12 @@ class TestPositionalJunctions: ║ Melbourne ║ 1566 ║ 3806092 ║ 646.9 ║ ║ Perth ║ 5386 ║ 1554769 ║ 869.4 ║ ╚═══════════╩══════╩═════════╩════════╝""".strip() - ) def test_hrule_all(self, city_data: PrettyTable) -> None: city_data.set_style(TableStyle.DOUBLE_BORDER) city_data.title = "Title" city_data.hrules = HRuleStyle.ALL - assert ( - city_data.get_string().strip() - == """ + assert city_data.get_string().strip() == """ ╔═════════════════════════════════════════════════╗ ║ Title ║ ╠═══════════╦══════╦════════════╦═════════════════╣ @@ -117,7 +113,6 @@ class TestPositionalJunctions: ╠═══════════╬══════╬════════════╬═════════════════╣ ║ Perth ║ 5386 ║ 1554769 ║ 869.4 ║ ╚═══════════╩══════╩════════════╩═════════════════╝""".strip() - ) def test_vrules_none(self, city_data: PrettyTable) -> None: city_data.set_style(TableStyle.DOUBLE_BORDER) @@ -141,9 +136,7 @@ class TestPositionalJunctions: city_data.set_style(TableStyle.DOUBLE_BORDER) city_data.vrules = VRuleStyle.FRAME city_data.title = "Title" - assert ( - city_data.get_string().strip() - == """ + assert city_data.get_string().strip() == """ ╔═════════════════════════════════════════════════╗ ║ Title ║ ╠═════════════════════════════════════════════════╣ @@ -157,7 +150,6 @@ class TestPositionalJunctions: ║ Melbourne 1566 3806092 646.9 ║ ║ Perth 5386 1554769 869.4 ║ ╚═════════════════════════════════════════════════╝""".strip() - ) class TestStyle: @@ -167,6 +159,8 @@ class TestStyle: pytest.param( TableStyle.DEFAULT, """ ++---------------------------------+ +| Table Caption | +---+---------+---------+---------+ | | Field 1 | Field 2 | Field 3 | +---+---------+---------+---------+ @@ -180,17 +174,21 @@ class TestStyle: pytest.param( TableStyle.MARKDOWN, # TODO fix """ +**Table Caption** + | | Field 1 | Field 2 | Field 3 | | :-: | :-----: | :-----: | :-----: | | 1 | value 1 | value2 | value3 | | 4 | value 4 | value5 | value6 | | 7 | value 7 | value8 | value9 | -""", +""".strip(), id="MARKDOWN", ), pytest.param( TableStyle.MSWORD_FRIENDLY, """ ++---------------------------------+ +| Table Caption | | | Field 1 | Field 2 | Field 3 | | 1 | value 1 | value2 | value3 | | 4 | value 4 | value5 | value6 | @@ -201,6 +199,8 @@ class TestStyle: pytest.param( TableStyle.ORGMODE, """ +|---------------------------------| +| Table Caption | |---+---------+---------+---------| | | Field 1 | Field 2 | Field 3 | |---+---------+---------+---------| @@ -214,6 +214,7 @@ class TestStyle: pytest.param( TableStyle.PLAIN_COLUMNS, """ +Table Caption Field 1 Field 2 Field 3 1 value 1 value2 value3 4 value 4 value5 value6 @@ -225,6 +226,8 @@ class TestStyle: TableStyle.RANDOM, """ '^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^' +% Table Caption % +'^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^' % 1 value 1 value2 value3% % 4 value 4 value5 value6% % 7 value 7 value8 value9% @@ -235,7 +238,9 @@ class TestStyle: pytest.param( TableStyle.DOUBLE_BORDER, """ -╔═══╦═════════╦═════════╦═════════╗ +╔═════════════════════════════════╗ +║ Table Caption ║ +╠═══╦═════════╦═════════╦═════════╣ ║ ║ Field 1 ║ Field 2 ║ Field 3 ║ ╠═══╬═════════╬═════════╬═════════╣ ║ 1 ║ value 1 ║ value2 ║ value3 ║ @@ -247,7 +252,9 @@ class TestStyle: pytest.param( TableStyle.SINGLE_BORDER, """ -┌───┬─────────┬─────────┬─────────┐ +┌─────────────────────────────────┐ +│ Table Caption │ +├───┬─────────┬─────────┬─────────┤ │ │ Field 1 │ Field 2 │ Field 3 │ ├───┼─────────┼─────────┼─────────┤ │ 1 │ value 1 │ value2 │ value3 │ @@ -256,12 +263,30 @@ class TestStyle: └───┴─────────┴─────────┴─────────┘ """, ), + pytest.param( + TableStyle.RST, + """ ++---------------------------------+ +| Table Caption | ++---+---------+---------+---------+ +| | Field 1 | Field 2 | Field 3 | ++===+=========+=========+=========+ +| 1 | value 1 | value2 | value3 | ++---+---------+---------+---------+ +| 4 | value 4 | value5 | value6 | ++---+---------+---------+---------+ +| 7 | value 7 | value8 | value9 | ++---+---------+---------+---------+ +""", + id="RST", + ), ], ) def test_style( self, helper_table: PrettyTable, style: TableStyle, expected: str ) -> None: random.seed(1234) + helper_table.title = "Table Caption" helper_table.set_style(style) assert helper_table.get_string().strip() == expected.strip() @@ -375,6 +400,22 @@ class TestStyle: """, id="SINGLE_BORDER", ), + pytest.param( + TableStyle.MARKDOWN, + TableStyle.RST, + """ ++---+---------+---------+---------+ +| | Field 1 | Field 2 | Field 3 | ++===+=========+=========+=========+ +| 1 | value 1 | value2 | value3 | ++---+---------+---------+---------+ +| 4 | value 4 | value5 | value6 | ++---+---------+---------+---------+ +| 7 | value 7 | value8 | value9 | ++---+---------+---------+---------+ +""", + id="RST", + ), ], ) def test_style_reset( @@ -523,7 +564,7 @@ class TestMultiPattern: | \x1b[38;5;228;5m ⚡\x1b[38;5;111;25mʻ ʻ\x1b[38;5;228;5m⚡\x1b[38;5;111;25mʻ ʻ \x1b[0m | \x1b[38;5;21;1m ‚ʻ\x1b[38;5;228;5m⚡\x1b[38;5;21;25mʻ‚\x1b[38;5;228;5m⚡\x1b[38;5;21;25m‚ʻ \x1b[0m | | \x1b[38;5;111m ʻ ʻ ʻ ʻ \x1b[0m | \x1b[38;5;21;1m ‚ʻ‚ʻ\x1b[38;5;228;5m⚡\x1b[38;5;21;25mʻ‚ʻ \x1b[0m | +-----------------+-----------------+ - """, # noqa: E501 + """, "Emoji table", ), ], @@ -539,15 +580,12 @@ class TestMultiPattern: def test_colored_table() -> None: table = PrettyTable(field_names=["Namespace", "Count"]) table.title = "\x1b[34mHere be Table caption\x1b[39m" - assert ( - table.get_string() - == """+-----------------------+ + assert table.get_string() == """+-----------------------+ | \x1b[34mHere be Table caption\x1b[39m | +-------------+---------+ | Namespace | Count | +-------------+---------+ +-------------+---------+""" - ) def test_link_and_color() -> None: @@ -560,9 +598,7 @@ def test_link_and_color() -> None: text = "Click \x1b[34mhere\x1b[39m" table.add_row([f"\033]8;;https://example.com\033\\{text}\033]8;;\033\\", "3"]) - assert ( - table.get_string() - == """\ + assert table.get_string() == """\ +------------+-------+ | Link | Count | +------------+-------+ @@ -570,7 +606,6 @@ def test_link_and_color() -> None: | No link | 2 | | \033]8;;https://example.com\033\\Click \x1b[34mhere\x1b[39m\033]8;;\033\\ | 3 | +------------+-------+""" - ) @pytest.mark.parametrize( @@ -585,7 +620,123 @@ def test_link_and_color() -> None: ("\033]8;;https://example.com\033\\\x1b[34mblue link\x1b[39m\033]8;;\033\\", 9), # link inside colour ("\x1b[34m\033]8;;https://example.com\033\\blue link\033]8;;\033\\\x1b[39m", 9), + # 中文 CJK wide characters + ("\u4e2d\u6587", 4), + # café (combining acute accent) + ("cafe\u0301", 4), + # 👨‍👩‍👧 ZWJ family + ("\U0001f468\u200d\U0001f469\u200d\U0001f467", 2), + # ☺️ VS16 emoji + ("\u263a\ufe0f", 2), + # 🇺🇸 regional flag + ("\U0001f1fa\U0001f1f8", 2), + # control code (bell) + ("abc\x07def", 6), ], ) def test__str_block_width(test_input: str, expected: int) -> None: assert _str_block_width(test_input) == expected + + +@pytest.mark.parametrize( + ["fields", "rows", "expected_file"], + [ + ( + ["Emoji", "Name"], + [ + ["\U0001f468\u200d\U0001f469\u200d\U0001f467", "Family"], + ["\U0001f1fa\U0001f1f8", "USA"], + ["Hi", "Text"], + ], + "table_complex_emoji.txt", + ), + ( + ["Word", "Lang"], + [["cafe\u0301", "FR"], ["cafe", "EN"]], + "table_combining_chars.txt", + ), + ( + ["CJK", "Width"], + [["\u4e2d\u6587", "4"], ["Test", "4"]], + "table_cjk.txt", + ), + ( + ["Status", "Count"], + [ + ["\x1b[32mOK\x1b[0m", "10"], + ["\x1b[31mFailed\x1b[0m", "2"], + ["Normal", "5"], + ], + "table_ansi_colors.txt", + ), + ], +) +def test_table_unicode_width( + fields: list[str], + rows: list[list[str]], + expected_file: str, +) -> None: + table = PrettyTable(fields) + for row in rows: + table.add_row(row) + with open(os.path.join(DATA_DIR, expected_file), encoding="utf-8") as fin: + expected_from_file = fin.read() + assert table.get_string().rstrip() == expected_from_file.rstrip() + + +@pytest.mark.parametrize( + ["align", "expected_file"], + [ + ("l", "table_align_left.txt"), + ("r", "table_align_right.txt"), + ("c", "table_align_center.txt"), + ], +) +def test_table_alignment_with_emoji( + align: Literal["l", "c", "r"], expected_file: str +) -> None: + table = PrettyTable(["Name"]) + table.align["Name"] = align + table.add_row(["\U0001f468\u200d\U0001f469\u200d\U0001f467"]) # 👨‍👩‍👧 + table.add_row(["Hi"]) + with open(os.path.join(DATA_DIR, expected_file), encoding="utf-8") as fin: + expected_from_file = fin.read() + assert table.get_string().rstrip() == expected_from_file.strip() + + +def test_ansi_wrap_width(): + """ANSI escape sequences should not count toward column width when wrapping.""" + table = PrettyTable(["Key", "Value"]) + table.header = False + table.max_width["Value"] = 35 + table.add_row( + [ + "NEW_ENVIRON", + "\x1b[38;5;208mOversharing: HOME, PWD, SHELL," + " SSH_AUTH_SOCK, XDG_SESSION_PATH\x1b[0m", + ] + ) + result = table.get_string() + for line in result.split("\n"): + if "|" not in line: + continue + parts = line.split("|") + value_cell = parts[2] if len(parts) > 2 else "" + assert "\x1b[38;5;208m" not in value_cell or "\x1b[0m" in value_cell + + +def test_ansi_wrap_no_bleed(): + """Wrapped ANSI-colored text must close SGR on every line.""" + table = PrettyTable(["Key", "Value"]) + table.header = False + table.max_width["Value"] = 30 + table.add_row( + [ + "TEST", + "\x1b[31mRed text that is long enough to wrap across lines\x1b[0m", + ] + ) + result = table.get_string() + for line in result.split("\n"): + if "\x1b[31m" in line: + assert "\x1b[0m" in line diff --git a/contrib/python/prettytable/py3/ya.make b/contrib/python/prettytable/py3/ya.make index 487fd84e582..1e97cf3a7d0 100644 --- a/contrib/python/prettytable/py3/ya.make +++ b/contrib/python/prettytable/py3/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(3.17.0) +VERSION(3.18.0) LICENSE(BSD-3-Clause) -- cgit v1.3