pytility 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pytility/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Initialisations."""
2
+
3
+ from importlib.metadata import version as _version
4
+
5
+ from .files import concat_files
6
+ from .iterables import arg_to_iter, batchify, clear_list, flatten, take_first, window
7
+ from .parsers import parse_bool, parse_date, parse_float, parse_int
8
+ from .strings import normalize_space, to_str, truncate
9
+
10
+ __version__ = _version("pytility")
11
+ VERSION = tuple(int(part) for part in __version__.split("."))
12
+
13
+ __all__ = (
14
+ "VERSION",
15
+ "__version__",
16
+ "arg_to_iter",
17
+ "batchify",
18
+ "clear_list",
19
+ "concat_files",
20
+ "flatten",
21
+ "normalize_space",
22
+ "parse_bool",
23
+ "parse_date",
24
+ "parse_float",
25
+ "parse_int",
26
+ "take_first",
27
+ "to_str",
28
+ "truncate",
29
+ "window",
30
+ )
pytility/files.py ADDED
@@ -0,0 +1,54 @@
1
+ """File utilities."""
2
+
3
+ import logging
4
+ import os
5
+ import shutil
6
+ from collections.abc import Iterable
7
+ from typing import TextIO
8
+
9
+ LOGGER = logging.getLogger(__name__)
10
+
11
+ FilePath = str | bytes | os.PathLike | TextIO
12
+
13
+
14
+ def _copy_file(src: FilePath, dst: TextIO, ensure_newline: bool) -> int:
15
+ if isinstance(src, (str, bytes, os.PathLike)):
16
+ LOGGER.debug("copy data from <%s>", src)
17
+ with open(src) as in_file:
18
+ return _copy_file(in_file, dst, ensure_newline)
19
+
20
+ shutil.copyfileobj(src, dst)
21
+
22
+ if not src.tell():
23
+ return 0
24
+
25
+ copied = src.tell()
26
+
27
+ if ensure_newline:
28
+ src.seek(src.tell() - 1)
29
+
30
+ if src.read(1) != "\n":
31
+ dst.write("\n")
32
+ copied += 1
33
+
34
+ return copied
35
+
36
+
37
+ def concat_files(
38
+ dst: FilePath, srcs: Iterable[FilePath], ensure_newline: bool = False
39
+ ) -> int:
40
+ """Concatenate files, returning the total number of bytes copied."""
41
+
42
+ if isinstance(dst, (str, bytes, os.PathLike)):
43
+ LOGGER.info("concatenating files into <%s>", dst)
44
+ with open(dst, "w") as out_file:
45
+ return concat_files(out_file, srcs, ensure_newline)
46
+
47
+ total = 0
48
+
49
+ for src in srcs:
50
+ total += _copy_file(src, dst, ensure_newline)
51
+
52
+ LOGGER.info("done concatenating, %d bytes in total", total)
53
+
54
+ return total
pytility/iterables.py ADDED
@@ -0,0 +1,64 @@
1
+ """Iterable utilities."""
2
+
3
+ from collections import OrderedDict
4
+ from collections.abc import Iterable
5
+ from itertools import groupby, tee
6
+ from typing import Any, TypeVar
7
+
8
+ ITERABLE_SINGLE_VALUES = (dict, str, bytes)
9
+ Typed = TypeVar("Typed")
10
+
11
+
12
+ def clear_list(items: Iterable[Typed | None]) -> list[Typed]:
13
+ """Return unique items in order of first ocurrence."""
14
+ return list(OrderedDict.fromkeys(filter(None, items)))
15
+
16
+
17
+ def flatten(*args: Any) -> Iterable:
18
+ """Flatten iterables of iterables recursively."""
19
+ for arg in args:
20
+ if not isinstance(arg, ITERABLE_SINGLE_VALUES) and hasattr(arg, "__iter__"):
21
+ for item in arg:
22
+ yield from flatten(item)
23
+ else:
24
+ yield arg
25
+
26
+
27
+ def arg_to_iter(arg: Any) -> Iterable:
28
+ """Wraps arg into tuple if not an iterable."""
29
+
30
+ if arg is None:
31
+ return ()
32
+
33
+ if not isinstance(arg, ITERABLE_SINGLE_VALUES) and hasattr(arg, "__iter__"):
34
+ return arg
35
+
36
+ return (arg,)
37
+
38
+
39
+ def take_first(items):
40
+ """Take first item that is not None or zero-length str."""
41
+
42
+ for item in arg_to_iter(items):
43
+ if item is not None and item != "":
44
+ return item
45
+
46
+ return None
47
+
48
+
49
+ def batchify(iterable, size):
50
+ """Make batches of given size."""
51
+ for _, group in groupby(enumerate(iterable), key=lambda x: x[0] // size):
52
+ yield (x[1] for x in group)
53
+
54
+
55
+ def window(iterable: Iterable[Typed], size: int = 2) -> Iterable[tuple[Typed, ...]]:
56
+ """Sliding window of an iterator."""
57
+
58
+ iterables = tee(iterable, size)
59
+
60
+ for num, itb in enumerate(iterables):
61
+ for _ in range(num):
62
+ next(itb, None)
63
+
64
+ return zip(*iterables, strict=False)
pytility/parsers.py ADDED
@@ -0,0 +1,104 @@
1
+ """Parsers."""
2
+
3
+ from datetime import date as date_cls
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+
8
+ def parse_int(string: Any, base: int = 10) -> int | None:
9
+ """Safely convert an object to int if possible, else return None."""
10
+
11
+ if isinstance(string, int):
12
+ return string
13
+
14
+ try:
15
+ return int(string, base=base)
16
+ except Exception:
17
+ pass
18
+
19
+ try:
20
+ return int(string)
21
+ except Exception:
22
+ pass
23
+
24
+ return None
25
+
26
+
27
+ def parse_float(number: Any) -> float | None:
28
+ """Safely convert an object to float if possible, else return None."""
29
+
30
+ try:
31
+ return float(number)
32
+ except Exception:
33
+ pass
34
+ return None
35
+
36
+
37
+ def parse_bool(item: Any) -> bool:
38
+ """Parses an item and converts it to a boolean."""
39
+
40
+ if isinstance(item, int):
41
+ return bool(item)
42
+ if item in ("True", "true", "Yes", "yes"):
43
+ return True
44
+ integer = parse_int(item)
45
+ if integer is not None:
46
+ return bool(integer)
47
+ return False
48
+
49
+
50
+ def _add_tz(date: datetime | None, tzinfo: timezone | None = None) -> datetime | None:
51
+ return (
52
+ date if not tzinfo or not date or date.tzinfo else date.replace(tzinfo=tzinfo)
53
+ )
54
+
55
+
56
+ def parse_date(
57
+ date: Any, tzinfo: timezone | None = None, format_str: str | None = None
58
+ ) -> datetime | None:
59
+ """Try to turn input into a datetime object."""
60
+
61
+ if not date:
62
+ return None
63
+
64
+ # already a datetime
65
+ if isinstance(date, datetime):
66
+ return _add_tz(date, tzinfo)
67
+
68
+ # date without time
69
+ if isinstance(date, date_cls):
70
+ return _add_tz(datetime(date.year, date.month, date.day), tzinfo=tzinfo)
71
+
72
+ # parse as epoch time
73
+ timestamp = parse_float(date)
74
+ if timestamp is not None:
75
+ return datetime.fromtimestamp(timestamp, tzinfo or timezone.utc)
76
+
77
+ if format_str:
78
+ try:
79
+ # parse as string in given format
80
+ return _add_tz(datetime.strptime(date, format_str), tzinfo)
81
+ except Exception:
82
+ pass
83
+
84
+ try:
85
+ import dateutil.parser
86
+
87
+ # parse as string
88
+ return _add_tz(dateutil.parser.parse(date), tzinfo)
89
+ except Exception:
90
+ pass
91
+
92
+ try:
93
+ # parse as (year, month, day, hour, minute, second, microsecond, tzinfo)
94
+ return datetime(*date)
95
+ except Exception:
96
+ pass
97
+
98
+ try:
99
+ # parse as time.struct_time
100
+ return datetime(*date[:6], tzinfo=tzinfo or timezone.utc)
101
+ except Exception:
102
+ pass
103
+
104
+ return None
pytility/py.typed ADDED
File without changes
pytility/strings.py ADDED
@@ -0,0 +1,65 @@
1
+ """String utilities."""
2
+
3
+ import re
4
+ import string as string_lib
5
+ from typing import Any
6
+
7
+ PRINTABLE_SET = frozenset(string_lib.printable)
8
+ NON_PRINTABLE_SET = frozenset(chr(i) for i in range(128)) - PRINTABLE_SET
9
+ NON_PRINTABLE_TRANSLATE = {ord(character): None for character in NON_PRINTABLE_SET}
10
+ REGEX_WORD = re.compile(r"^\w+")
11
+
12
+
13
+ def to_str(string: Any, encoding: str = "utf-8") -> str | None:
14
+ """Safely returns either string or None."""
15
+
16
+ string = (
17
+ string
18
+ if isinstance(string, str)
19
+ else string.decode(encoding)
20
+ if isinstance(string, bytes)
21
+ else None
22
+ )
23
+
24
+ return string.translate(NON_PRINTABLE_TRANSLATE) if string is not None else None
25
+
26
+
27
+ def normalize_space(item: Any, preserve_newline: bool = False) -> str:
28
+ """Normalize space in a string."""
29
+
30
+ item = to_str(item)
31
+
32
+ if not item:
33
+ return ""
34
+
35
+ if preserve_newline:
36
+ return "\n".join(normalize_space(line) for line in item.splitlines()).strip()
37
+
38
+ return " ".join(item.split())
39
+
40
+
41
+ def truncate(
42
+ string: str,
43
+ length: int,
44
+ ellipsis: str = "[…]",
45
+ respect_word: bool = False,
46
+ ) -> str:
47
+ """Truncates a string at given length."""
48
+
49
+ if length < 0 or len(string) <= length + len(ellipsis):
50
+ return string
51
+
52
+ string_trunc = string[:length]
53
+
54
+ if respect_word:
55
+ match = REGEX_WORD.match(string[length:])
56
+ if match:
57
+ string_trunc += match.group(0)
58
+
59
+ if string_trunc.rstrip().endswith(ellipsis):
60
+ return string_trunc.rstrip()
61
+
62
+ if not respect_word or not string_trunc or string_trunc[-1].isspace():
63
+ return string_trunc + ellipsis
64
+
65
+ return f"{string_trunc} {ellipsis}"
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytility
3
+ Version: 1.0.0
4
+ Summary: A lean collection of Python utilities
5
+ Project-URL: Homepage, https://gitlab.com/mshepherd/pytility
6
+ Project-URL: Documentation, https://gitlab.com/mshepherd/pytility/blob/master/README.md
7
+ Project-URL: Source, https://gitlab.com/mshepherd/pytility
8
+ Project-URL: Tracker, https://gitlab.com/mshepherd/pytility/issues
9
+ Author-email: Markus Shepherd <markus.r.shepherd@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: pytility,utilities,utility
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Requires-Python: >=3.10
20
+ Provides-Extra: dates
21
+ Requires-Dist: python-dateutil; extra == 'dates'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # 🛠 Pytility 🛠
25
+
26
+ [![PyPI](https://img.shields.io/pypi/v/pytility?style=flat-square)](https://pypi.python.org/pypi/pytility/)
27
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pytility?style=flat-square)](https://pypi.python.org/pypi/pytility/)
28
+ [![PyPI - License](https://img.shields.io/pypi/l/pytility?style=flat-square)](https://pypi.python.org/pypi/pytility/)
29
+
30
+ ---
31
+
32
+ **Source Code**: [https://gitlab.com/mshepherd/pytility](https://gitlab.com/mshepherd/pytility)
33
+
34
+ **PyPI**: [https://pypi.org/project/pytility/](https://pypi.org/project/pytility/)
35
+
36
+ ---
37
+
38
+ A lean collection of Python utilities — small, dependency-free helpers for
39
+ strings, iterables, parsing, and files, with no runtime dependencies unless
40
+ you opt into the `dates` extra.
41
+
42
+ ## Installation
43
+
44
+ ```sh
45
+ pip install pytility
46
+ ```
47
+
48
+ Date parsing via [`python-dateutil`](https://pypi.org/project/python-dateutil/)
49
+ is an optional extra:
50
+
51
+ ```sh
52
+ pip install pytility[dates]
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ ```python
58
+ import pytility
59
+
60
+ pytility.normalize_space(" too much space ") # "too much space"
61
+ pytility.truncate("a long string", 6) # "a long[…]"
62
+ pytility.parse_bool("yes") # True
63
+ pytility.parse_int("2a", base=16) # 42
64
+ pytility.clear_list([1, 1, None, 2, "", 3]) # [1, 2, 3]
65
+ list(pytility.flatten([1, [2, [3, 4]], 5])) # [1, 2, 3, 4, 5]
66
+ ```
67
+
68
+ ### API reference
69
+
70
+ #### Strings (`pytility.strings`)
71
+
72
+ * `to_str(string, encoding="utf-8") -> str | None` — coerces `str` or
73
+ `bytes` to `str`, stripping non-printable characters; anything else
74
+ returns `None`.
75
+ * `normalize_space(item, preserve_newline=False) -> str` — collapses runs of
76
+ whitespace to single spaces; with `preserve_newline=True`, newlines are
77
+ kept but whitespace is normalized within each line.
78
+ * `truncate(string, length, ellipsis="[…]", respect_word=False) -> str` —
79
+ truncates `string` to `length` characters plus an ellipsis; with
80
+ `respect_word=True`, extends the cut to the end of the word it lands in
81
+ rather than splitting it.
82
+
83
+ #### Iterables (`pytility.iterables`)
84
+
85
+ * `arg_to_iter(arg) -> Iterable` — wraps a non-iterable (or a `str`/`bytes`/
86
+ `dict`, which are treated as scalars) in a 1-tuple; `None` becomes `()`;
87
+ anything else iterable is returned as-is.
88
+ * `clear_list(items) -> list` — unique items in order of first occurrence,
89
+ with falsy values (`None`, `""`, `0`, …) dropped.
90
+ * `flatten(*args) -> Iterable` — recursively flattens nested iterables;
91
+ `str`/`bytes`/`dict` are treated as scalars, not flattened further.
92
+ * `take_first(items) -> Any | None` — the first item that isn't `None` or
93
+ `""`, or `None` if there isn't one.
94
+ * `batchify(iterable, size) -> Iterable[Iterable]` — splits `iterable` into
95
+ chunks of at most `size` items.
96
+ * `window(iterable, size=2) -> Iterable[tuple]` — a sliding window of
97
+ `size` consecutive items over `iterable`.
98
+
99
+ #### Parsers (`pytility.parsers`)
100
+
101
+ * `parse_int(string, base=10) -> int | None` — safely converts to `int`,
102
+ or `None` on failure.
103
+ * `parse_float(number) -> float | None` — safely converts to `float`, or
104
+ `None` on failure.
105
+ * `parse_bool(item) -> bool` — recognizes `int`s, the strings `"True"`,
106
+ `"true"`, `"Yes"`, `"yes"`, and anything else `parse_int` can parse as
107
+ non-zero; everything else is `False`.
108
+ * `parse_date(date, tzinfo=None, format_str=None) -> datetime | None` —
109
+ parses `datetime`/`date` objects, epoch timestamps, a given
110
+ `format_str`, free-form strings (via `python-dateutil`, if installed),
111
+ or `(year, month, day, ...)` tuples / `time.struct_time`. Falsy input
112
+ (including `0`) returns `None` before any parsing is attempted.
113
+
114
+ #### Files (`pytility.files`)
115
+
116
+ * `concat_files(dst, srcs, ensure_newline=False) -> int` — concatenates
117
+ `srcs` into `dst` (paths or open file objects), returning the total
118
+ bytes written. With `ensure_newline=True`, a newline is appended after
119
+ every source that doesn't already end in one — including the last.
120
+
121
+ ## Development
122
+
123
+ * Clone this repository
124
+ * Requirements:
125
+ * [uv](https://docs.astral.sh/uv/)
126
+ * Python 3.10+
127
+ * Create a virtual environment and install the dependencies
128
+
129
+ ```sh
130
+ uv sync
131
+ ```
132
+
133
+ ### Testing
134
+
135
+ ```sh
136
+ uv run pytest
137
+ ```
138
+
139
+ ### Pre-commit
140
+
141
+ Pre-commit hooks run the auto-formatter and linter (`ruff`), the type
142
+ checker (`ty`), and other housekeeping checks so the changeset is in good
143
+ shape before a commit happens.
144
+
145
+ Install the hooks with (runs on every commit):
146
+
147
+ ```sh
148
+ uv run pre-commit install
149
+ ```
150
+
151
+ Or run all checks manually against every file:
152
+
153
+ ```sh
154
+ uv run pre-commit run --all-files
155
+ ```
156
+
157
+ CI (`.gitlab-ci.yml`) runs the same lint hooks, the test suite across
158
+ Python 3.10–3.14, and a package build on every push.
159
+
160
+ ### Releasing
161
+
162
+ This project is published to PyPI manually — there's no CI publish job or
163
+ GitHub-style draft release here, just these steps run locally:
164
+
165
+ ```sh
166
+ # 1. Bump version (major|minor|patch|stable|alpha|beta|rc|post|dev, or pass an explicit value)
167
+ uv version --bump patch
168
+ VERSION=$(uv version --short)
169
+
170
+ # 2. Commit the version bump
171
+ git add pyproject.toml uv.lock
172
+ git commit -m "Release $VERSION"
173
+
174
+ # 3. Tag and push to GitLab (the only remote this repo has)
175
+ git tag "v$VERSION"
176
+ git push gitlab master
177
+ git push gitlab "v$VERSION"
178
+
179
+ # 4. Build and publish to PyPI (needs a token, e.g. via a UV_PUBLISH_TOKEN env var)
180
+ uv build
181
+ uv publish
182
+ ```
183
+
184
+ Note that `uv version` only edits `pyproject.toml`; `uv.lock` is re-synced
185
+ alongside it since it also records the project's own version.
@@ -0,0 +1,10 @@
1
+ pytility/__init__.py,sha256=u_fOHyunMkqfgMDa1Dr0QnYf2rD40QqabhURJMEoa9k,709
2
+ pytility/files.py,sha256=SPKRn60zMcuUlVIEVhOsKhCM_iGmjf3gsJNBK6Uk3LA,1307
3
+ pytility/iterables.py,sha256=uQPC9X_WoHgJU6HzBm30MypaCj8LjtJtbF_WZiRnz4A,1676
4
+ pytility/parsers.py,sha256=YEiGDZ2PUPI783A7flyZUmR6xrSjauQ8I0-uSj2tdSg,2493
5
+ pytility/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ pytility/strings.py,sha256=DWbK9XlAII43ued2HUj670rBMVBaI7hoUuLq-ke8ScI,1672
7
+ pytility-1.0.0.dist-info/METADATA,sha256=G2y9RQMc1LIzX4ewrdiMinFNP4ci02dBkd62dP95M8M,6381
8
+ pytility-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ pytility-1.0.0.dist-info/licenses/LICENSE,sha256=WcYkIFKvE2InORAE70AfXkBoQqXLLRsXETBDkNkwxNc,1072
10
+ pytility-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 Markus Shepherd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.