Skip to content

Commit 1703f77

Browse files
committed
Tests and improvements
1 parent c74bf89 commit 1703f77

17 files changed

Lines changed: 604 additions & 235 deletions

.github/workflows/cd.yml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,6 @@ jobs:
2626
- name: Update pip
2727
run: |
2828
python -m pip install --upgrade pip
29-
- name: Mypy check
30-
run: |
31-
pip install mypy
32-
mypy --python-version 3.7 src/
33-
- name: Test
34-
run: |
35-
pip install pytest
36-
pytest src/tsidpy/*.py --doctest-modules
3729
- name: Build package
3830
run: |
3931
pip install build

.github/workflows/ci.yml

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,8 @@ permissions:
99
contents: read
1010

1111
jobs:
12-
deploy:
13-
12+
mypy:
1413
runs-on: ubuntu-latest
15-
1614
steps:
1715
- uses: actions/checkout@v3
1816
- name: Set up Python
@@ -26,7 +24,26 @@ jobs:
2624
run: |
2725
pip install mypy
2826
mypy --python-version 3.7 src/
27+
28+
test:
29+
runs-on: ubuntu-latest
30+
strategy:
31+
fail-fast: false
32+
matrix:
33+
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12']
34+
35+
steps:
36+
- uses: actions/checkout@v3
37+
- name: Set up Python
38+
uses: actions/setup-python@v3
39+
with:
40+
python-version: ${{ matrix.python-version }}
41+
- name: Update pip
42+
run: |
43+
python -m pip install --upgrade pip
44+
- name: Install package and test dependencies
45+
run: |
46+
pip install -e .[test]
2947
- name: Test
3048
run: |
31-
pip install pytest
32-
pytest src/tsidpy/*.py --doctest-modules
49+
pytest --doctest-modules src/tsidpy tests -m "not perf"

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88

9+
## [Unreleased]
10+
11+
### Added
12+
13+
- Automated `pytest` coverage for the public API, deterministic generator behavior, doctests, and opt-in performance benchmarks.
14+
15+
### Changed
16+
17+
- Reduced `TSIDGenerator` hot-path overhead by shrinking the locked section and precomputing the fixed node bit segment.
18+
- Optimized generic base decoding by replacing repeated `ALPHABET.index()` lookups with a precomputed lookup table.
19+
- Added fast hexadecimal paths for `TSID.to_string('X'/'x')` and `TSID.from_string('X'/'x')`.
20+
21+
### Performance
22+
23+
- Local `perf` benchmark results on `macOS-15.7.3-arm64-arm-64bit`:
24+
- `tsid_from_string_X` improved from about `0.66M ops/s` to about `3.43M ops/s`.
25+
- `tsid_to_string_X` improved from about `0.82M ops/s` to about `4.18M ops/s`.
26+
- Shared-generator throughput improved from about `1.58M ops/s` to about `1.68M ops/s` in the 1-thread case, with similar throughput retained under multi-thread contention.
27+
928
## [1.1.5] - 2023-11-28
1029

1130
### Added

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
clean:
33
rm -rf dist build tsidpy.egg-info
44

5+
tests:
6+
python3 -m pytest --doctest-modules src/tsidpy tests -m "not perf"
7+
8+
bench:
9+
python3 -m pytest tests -m perf --run-perf -s
10+
511
build:
612
python3 -m build
713

pyproject.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,23 @@ classifiers = [
1919
"Typing :: Typed",
2020
]
2121

22+
[project.optional-dependencies]
23+
test = [
24+
"pytest>=7.0",
25+
]
26+
2227
[project.urls]
2328
"Homepage" = "https://github.com/luismedel/tsid-python"
2429

2530
[tool.setuptools.package-data]
2631
tsidpy = ["py.typed"]
32+
33+
[tool.pytest.ini_options]
34+
addopts = "--strict-markers"
35+
doctest_optionflags = ["ELLIPSIS"]
36+
markers = [
37+
"perf: opt-in performance and benchmark tests",
38+
]
39+
testpaths = [
40+
"tests",
41+
]

src/tsidpy/basen.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
# Base 62
55
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
6+
ALPHABET_INDEX = {char: index for index, char in enumerate(ALPHABET)}
67

78

89
def encode(
@@ -29,6 +30,6 @@ def decode(value: str, base: int) -> int:
2930

3031
for c in value:
3132
result *= base
32-
result += ALPHABET.index(c)
33+
result += ALPHABET_INDEX[c]
3334

3435
return result

src/tsidpy/tsid.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -247,10 +247,9 @@ def to_string(self, fmt: str = 'S') -> str:
247247
elif fmt == 's': # canonical string in lower case
248248
result = self._to_canonical_string().lower()
249249
elif fmt == 'X': # hexadecimal in upper case
250-
result = encode(self.number, 16, min_length=TSID_BYTES*2)
250+
result = format(self.number, '016X')
251251
elif fmt == 'x': # hexadecimal in lower case
252-
result = encode(self.number, 16, min_length=TSID_BYTES*2)
253-
result = result.lower()
252+
result = format(self.number, '016x')
254253
elif fmt == 'd': # base-10
255254
result = encode(self.number, 10)
256255
elif fmt == 'z': # base-62
@@ -339,13 +338,13 @@ def from_string(value: str, fmt: str = 'S') -> 'TSID':
339338
raise ValueError(f'Invalid TSID string: '
340339
f'(len={len(value)} chars, '
341340
f'but expected {TSID_HEXCHARS})')
342-
number = decode(value, 16)
341+
number = int(value, 16)
343342
elif fmt == 'x': # hexadecimal in lower case
344343
if len(value) != TSID_HEXCHARS:
345344
raise ValueError(f'Invalid TSID string: '
346345
f'(len={len(value)} chars, '
347346
f'but expected {TSID_HEXCHARS})')
348-
number = decode(value.upper(), 16)
347+
number = int(value, 16)
349348
elif fmt == 'd': # base-10
350349
number = decode(value, 10)
351350
elif fmt == 'z': # base-62
@@ -425,16 +424,22 @@ def __init__(
425424
f'node_bits=={node_bits}')
426425

427426
self._epoch: float = epoch
427+
self._epoch_millis: int = int(epoch)
428428
self._node_bits: int = node_bits
429429
self._counter_bits: int = RANDOM_BITS - node_bits
430430
self.counter: int = self.random_fn(self._counter_bits)
431431

432-
self._millis: float = time.time() * 1000
432+
self._millis: int = self._current_millis()
433433
self._counter_mask: int = RANDOM_MASK >> node_bits
434434
self._node_mask: int = RANDOM_MASK >> self._counter_bits
435+
self._node_value: int = (self.node & self._node_mask) << self._counter_bits
435436

436437
self._lock = threading.Lock()
437438

439+
@staticmethod
440+
def _current_millis() -> int:
441+
return int(time.time() * 1000)
442+
438443
def create(self) -> TSID:
439444
"""
440445
>>> ### Test node extraction ------------------------------
@@ -485,10 +490,10 @@ def create(self) -> TSID:
485490
True
486491
"""
487492
with self._lock:
488-
current_millis: float = time.time() * 1000
493+
current_millis: int = self._current_millis()
489494

490495
# If same millisecond, increment counter
491-
if current_millis - self._millis < 1:
496+
if current_millis - self._millis <= 1:
492497
self.counter += 1
493498

494499
# If the counter overflows, go to the next millisecond
@@ -500,13 +505,13 @@ def create(self) -> TSID:
500505
rnd: int = self.random_fn(self._counter_bits)
501506
self.counter = rnd & self._counter_mask
502507

503-
millis = int(self._millis - self._epoch) << RANDOM_BITS
504-
node = (self.node & self._node_mask) << self._counter_bits
508+
millis = self._millis
505509
counter = self.counter & self._counter_mask
506510

507-
result = TSID(millis + node + counter)
508-
result._epoch = self._epoch
509-
return result
511+
millis_value = (millis - self._epoch_millis) << RANDOM_BITS
512+
result = TSID(millis_value + self._node_value + counter)
513+
result._epoch = self._epoch
514+
return result
510515

511516

512517
_default_generator = TSIDGenerator(node_bits=0)

tests/collision_test.py

Lines changed: 0 additions & 74 deletions
This file was deleted.

tests/conftest.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import sys
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
7+
ROOT = Path(__file__).resolve().parents[1]
8+
SRC = ROOT / "src"
9+
10+
if str(SRC) not in sys.path:
11+
sys.path.insert(0, str(SRC))
12+
13+
14+
def pytest_addoption(parser):
15+
parser.addoption(
16+
"--run-perf",
17+
action="store_true",
18+
default=False,
19+
help="run performance benchmarks marked with 'perf'",
20+
)
21+
parser.addoption(
22+
"--perf-loops",
23+
action="store",
24+
type=int,
25+
default=50000,
26+
help="iterations used by performance benchmarks",
27+
)
28+
parser.addoption(
29+
"--perf-repeats",
30+
action="store",
31+
type=int,
32+
default=5,
33+
help="number of repeated runs per performance scenario",
34+
)
35+
36+
37+
def pytest_collection_modifyitems(config, items):
38+
if config.getoption("--run-perf"):
39+
return
40+
41+
skip_perf = pytest.mark.skip(reason="use --run-perf to run performance tests")
42+
for item in items:
43+
if "perf" in item.keywords:
44+
item.add_marker(skip_perf)

tests/generator_test.py

Lines changed: 0 additions & 27 deletions
This file was deleted.

0 commit comments

Comments
 (0)