xaeian 0.7.2__tar.gz → 0.7.4__tar.gz
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.
- {xaeian-0.7.2 → xaeian-0.7.4}/PKG-INFO +15 -5
- {xaeian-0.7.2 → xaeian-0.7.4}/pyproject.toml +9 -2
- {xaeian-0.7.2 → xaeian-0.7.4}/readme.md +5 -4
- xaeian-0.7.4/tests/test_colors.py +17 -0
- xaeian-0.7.4/tests/test_crc.py +157 -0
- xaeian-0.7.4/tests/test_cstruct.py +127 -0
- xaeian-0.7.4/tests/test_db_kv_common.py +128 -0
- xaeian-0.7.4/tests/test_db_sqlite.py +221 -0
- xaeian-0.7.4/tests/test_db_utils.py +191 -0
- xaeian-0.7.4/tests/test_files.py +293 -0
- xaeian-0.7.4/tests/test_files_json_smart.py +101 -0
- xaeian-0.7.4/tests/test_files_path.py +140 -0
- xaeian-0.7.4/tests/test_log.py +90 -0
- xaeian-0.7.4/tests/test_table.py +151 -0
- xaeian-0.7.4/tests/test_xstring.py +106 -0
- xaeian-0.7.4/tests/test_xtime.py +165 -0
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/__init__.py +5 -2
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/__main__.py +10 -5
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cli/_args.py +7 -3
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cli/dupes.py +2 -2
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cli/fonts.py +15 -16
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cli/tree.py +2 -2
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cli/wifi.py +2 -2
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cmd.py +2 -2
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/colors.py +2 -0
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/crc.py +4 -5
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cstruct.py +8 -7
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/__init__.py +6 -4
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/abstract.py +1 -2
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/abstract_async.py +5 -4
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/kv.py +5 -4
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/kv_async.py +5 -4
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/kv_common.py +6 -6
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/mysql.py +4 -10
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/mysql_async.py +6 -14
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/postgres.py +3 -10
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/postgres_async.py +21 -15
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/sqlite.py +4 -11
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/sqlite_async.py +6 -15
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/utils.py +26 -1
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/dsp.py +23 -23
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/__init__.py +2 -2
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/ee.py +5 -5
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/fp.py +14 -12
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/kicad.py +14 -12
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/kicad_clean.py +14 -16
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/spice.py +19 -19
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/sym.py +15 -15
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/__init__.py +2 -2
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/bound.py +0 -4
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/config.py +1 -8
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/csv.py +2 -2
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/dir.py +1 -1
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/ini.py +12 -5
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/json.py +1 -1
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/yaml.py +18 -9
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files_async.py +14 -3
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/log.py +15 -15
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/__init__.py +1 -1
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/ico.py +7 -6
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/img.py +9 -6
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/meta.py +2 -2
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/min.py +4 -4
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/pdf.py +5 -5
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/utils.py +1 -1
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/net/ftp.py +35 -23
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/net/sftp.py +38 -38
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/plot.py +148 -149
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/serial/__init__.py +2 -0
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/serial/port.py +5 -3
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/serial/rec.py +3 -1
- xaeian-0.7.2/xaeian/serial/rec-app.py → xaeian-0.7.4/xaeian/serial/rec_app.py +3 -1
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/serial/sh.py +7 -7
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/table.py +7 -7
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/xstring.py +15 -4
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/xtime.py +11 -9
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian.egg-info/PKG-INFO +15 -5
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian.egg-info/SOURCES.txt +14 -1
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian.egg-info/requires.txt +12 -0
- {xaeian-0.7.2 → xaeian-0.7.4}/setup.cfg +0 -0
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cli/__init__.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/errors.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/file.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/path.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/net/__init__.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian.egg-info/dependency_links.txt +0 -0
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian.egg-info/entry_points.txt +0 -0
- {xaeian-0.7.2 → xaeian-0.7.4}/xaeian.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xaeian
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.4
|
|
4
4
|
Summary: Python utilities for files, strings, time, serial, structs, media, electronics, plotting, and database and more...
|
|
5
5
|
Author: Xaeian
|
|
6
6
|
License: MIT
|
|
@@ -29,9 +29,15 @@ Requires-Dist: pypdf; extra == "media"
|
|
|
29
29
|
Provides-Extra: plot
|
|
30
30
|
Requires-Dist: matplotlib; extra == "plot"
|
|
31
31
|
Requires-Dist: numpy; extra == "plot"
|
|
32
|
+
Provides-Extra: serial
|
|
33
|
+
Requires-Dist: pyserial; extra == "serial"
|
|
34
|
+
Provides-Extra: sftp
|
|
35
|
+
Requires-Dist: paramiko; extra == "sftp"
|
|
32
36
|
Provides-Extra: time
|
|
33
37
|
Requires-Dist: pytz; extra == "time"
|
|
34
38
|
Requires-Dist: tzlocal; extra == "time"
|
|
39
|
+
Provides-Extra: yaml
|
|
40
|
+
Requires-Dist: pyyaml; extra == "yaml"
|
|
35
41
|
Provides-Extra: all
|
|
36
42
|
Requires-Dist: Pillow; extra == "all"
|
|
37
43
|
Requires-Dist: PyMuPDF; extra == "all"
|
|
@@ -40,10 +46,13 @@ Requires-Dist: aiosqlite; extra == "all"
|
|
|
40
46
|
Requires-Dist: asyncpg; extra == "all"
|
|
41
47
|
Requires-Dist: matplotlib; extra == "all"
|
|
42
48
|
Requires-Dist: numpy; extra == "all"
|
|
49
|
+
Requires-Dist: paramiko; extra == "all"
|
|
43
50
|
Requires-Dist: psycopg2-binary; extra == "all"
|
|
44
51
|
Requires-Dist: pymysql; extra == "all"
|
|
45
52
|
Requires-Dist: pypdf; extra == "all"
|
|
53
|
+
Requires-Dist: pyserial; extra == "all"
|
|
46
54
|
Requires-Dist: pytz; extra == "all"
|
|
55
|
+
Requires-Dist: pyyaml; extra == "all"
|
|
47
56
|
Requires-Dist: scipy; extra == "all"
|
|
48
57
|
Requires-Dist: sexpdata; extra == "all"
|
|
49
58
|
Requires-Dist: tzlocal; extra == "all"
|
|
@@ -121,12 +130,13 @@ db.find("users", order="name", limit=10)
|
|
|
121
130
|
async with db.transaction():
|
|
122
131
|
db.update("users", {"verified": True}, "id = ?", 42)
|
|
123
132
|
|
|
124
|
-
# Serial recorders: threaded read
|
|
125
|
-
from xaeian.serial import Recorder
|
|
133
|
+
# Serial recorders: threaded read, latest value via .value
|
|
134
|
+
from xaeian.serial import Recorder
|
|
126
135
|
recs = [Recorder(p, name=n, regex=Recorder.SCI_NORM)
|
|
127
136
|
for n, p in {"U1": "COM7", "I1": "COM8"}.items()]
|
|
128
|
-
|
|
129
|
-
|
|
137
|
+
for r in recs: r.start() # background reader threads
|
|
138
|
+
... # app-side reap loop snapshots r.value (CSV, DB, MQTT)
|
|
139
|
+
for r in recs: r.stop()
|
|
130
140
|
|
|
131
141
|
# Plot: fluent, stacked panels, auto datetime
|
|
132
142
|
from xaeian.plot import Plot
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "xaeian"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.4"
|
|
8
8
|
description = "Python utilities for files, strings, time, serial, structs, media, electronics, plotting, and database and more..."
|
|
9
9
|
readme = "readme.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -20,8 +20,11 @@ dsp = ["numpy", "scipy"]
|
|
|
20
20
|
eda = ["PyMuPDF", "pypdf", "sexpdata"]
|
|
21
21
|
media = ["Pillow", "PyMuPDF", "pypdf"]
|
|
22
22
|
plot = ["matplotlib", "numpy"]
|
|
23
|
+
serial = ["pyserial"]
|
|
24
|
+
sftp = ["paramiko"]
|
|
23
25
|
time = ["pytz", "tzlocal"]
|
|
24
|
-
|
|
26
|
+
yaml = ["pyyaml"]
|
|
27
|
+
all = ["Pillow", "PyMuPDF", "aiomysql", "aiosqlite", "asyncpg", "matplotlib", "numpy", "paramiko", "psycopg2-binary", "pymysql", "pypdf", "pyserial", "pytz", "pyyaml", "scipy", "sexpdata", "tzlocal"]
|
|
25
28
|
|
|
26
29
|
[project.scripts]
|
|
27
30
|
xn = "xaeian.__main__:main"
|
|
@@ -31,3 +34,7 @@ Repository = "https://github.com/Xaeian/Python"
|
|
|
31
34
|
|
|
32
35
|
[tool.setuptools.packages.find]
|
|
33
36
|
include = ["xaeian*"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
python_functions = ["*"]
|
|
@@ -71,12 +71,13 @@ db.find("users", order="name", limit=10)
|
|
|
71
71
|
async with db.transaction():
|
|
72
72
|
db.update("users", {"verified": True}, "id = ?", 42)
|
|
73
73
|
|
|
74
|
-
# Serial recorders: threaded read
|
|
75
|
-
from xaeian.serial import Recorder
|
|
74
|
+
# Serial recorders: threaded read, latest value via .value
|
|
75
|
+
from xaeian.serial import Recorder
|
|
76
76
|
recs = [Recorder(p, name=n, regex=Recorder.SCI_NORM)
|
|
77
77
|
for n, p in {"U1": "COM7", "I1": "COM8"}.items()]
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
for r in recs: r.start() # background reader threads
|
|
79
|
+
... # app-side reap loop snapshots r.value (CSV, DB, MQTT)
|
|
80
|
+
for r in recs: r.stop()
|
|
80
81
|
|
|
81
82
|
# Plot: fluent, stacked panels, auto datetime
|
|
82
83
|
from xaeian.plot import Plot
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# tests/test_colors.py
|
|
2
|
+
|
|
3
|
+
"""ANSI color codes and pre-formatted level indicators."""
|
|
4
|
+
|
|
5
|
+
from xaeian import Color, Ico
|
|
6
|
+
|
|
7
|
+
def color_codes_are_ansi_escape_sequences():
|
|
8
|
+
assert Color.RED.startswith("\033[") and Color.RED.endswith("m")
|
|
9
|
+
assert Color.END == "\033[0m"
|
|
10
|
+
|
|
11
|
+
def ico_wraps_label_in_color_and_reset():
|
|
12
|
+
assert Ico.ERR == f"{Color.RED}ERR{Color.END}"
|
|
13
|
+
assert Ico.INF == f"{Color.BLUE}INF{Color.END}"
|
|
14
|
+
assert Ico.OK == f"{Color.GREEN}OK{Color.END}"
|
|
15
|
+
|
|
16
|
+
def ico_gap_is_blank_padding():
|
|
17
|
+
assert Ico.GAP == " " and Ico.GAP.strip() == ""
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# tests/test_crc.py
|
|
2
|
+
|
|
3
|
+
"""CRC checksums: catalog check values, round-trips, framing, parameter effects."""
|
|
4
|
+
|
|
5
|
+
import zlib
|
|
6
|
+
import pytest
|
|
7
|
+
from xaeian.crc import (
|
|
8
|
+
CRC, reflect_bit,
|
|
9
|
+
crc32_iso, crc32_aixm, crc32_autosar, crc32_cksum,
|
|
10
|
+
crc16_kermit, crc16_modbus, crc16_buypass,
|
|
11
|
+
crc8_maxim, crc8_smbus,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
CHECK = b"123456789" # the standard CRC catalog test vector
|
|
15
|
+
|
|
16
|
+
#---------------------------------------------------------------------------------- reflect_bit
|
|
17
|
+
|
|
18
|
+
def reflect_reverses_bit_order():
|
|
19
|
+
assert reflect_bit(0b1100, 4) == 0b0011
|
|
20
|
+
assert reflect_bit(0b10000000, 8) == 0b00000001
|
|
21
|
+
|
|
22
|
+
def reflect_is_its_own_inverse():
|
|
23
|
+
for value in (0x00, 0x01, 0x80, 0xA5, 0xFF):
|
|
24
|
+
assert reflect_bit(reflect_bit(value, 8), 8) == value
|
|
25
|
+
|
|
26
|
+
def reflect_palindrome_is_unchanged():
|
|
27
|
+
assert reflect_bit(0b1001_1001, 8) == 0b1001_1001
|
|
28
|
+
|
|
29
|
+
#------------------------------------------------------------------------------- catalog values
|
|
30
|
+
|
|
31
|
+
# Every predefined instance matched against its published check value for "123456789".
|
|
32
|
+
CATALOG = [
|
|
33
|
+
(crc32_iso, 0xCBF43926), # standard CRC-32 (Ethernet, ZIP, PNG)
|
|
34
|
+
(crc32_aixm, 0x3010BF7F),
|
|
35
|
+
(crc32_autosar, 0x1697D06A),
|
|
36
|
+
(crc32_cksum, 0x765E7680),
|
|
37
|
+
(crc16_kermit, 0x2189),
|
|
38
|
+
(crc16_modbus, 0x374B), # 0x4B37 byte-swapped by invertOut
|
|
39
|
+
(crc16_buypass, 0xFEE8),
|
|
40
|
+
(crc8_maxim, 0xA1),
|
|
41
|
+
(crc8_smbus, 0xF4),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize("crc, expected", CATALOG)
|
|
45
|
+
def predefined_matches_catalog_check_value(crc, expected):
|
|
46
|
+
assert crc.checksum(CHECK) == expected
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize("crc, _", CATALOG)
|
|
49
|
+
def checksum_is_deterministic(crc, _):
|
|
50
|
+
assert crc.checksum(CHECK) == crc.checksum(CHECK)
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize("crc, _", CATALOG)
|
|
53
|
+
def checksum_fits_width(crc, _):
|
|
54
|
+
assert 0 <= crc.checksum(b"random payload") < (1 << crc.width)
|
|
55
|
+
|
|
56
|
+
#---------------------------------------------------------------------------- to_bytes / to_int
|
|
57
|
+
|
|
58
|
+
@pytest.mark.parametrize("crc", [crc8_smbus, crc16_kermit, crc32_iso])
|
|
59
|
+
def bytes_int_round_trip(crc):
|
|
60
|
+
value = crc.checksum(CHECK)
|
|
61
|
+
assert crc.to_int(crc.to_bytes(value)) == value
|
|
62
|
+
|
|
63
|
+
@pytest.mark.parametrize("crc, n", [(crc8_smbus, 1), (crc16_kermit, 2), (crc32_iso, 4)])
|
|
64
|
+
def to_bytes_length_follows_width(crc, n):
|
|
65
|
+
assert len(crc.to_bytes(crc.checksum(CHECK))) == n
|
|
66
|
+
|
|
67
|
+
def to_bytes_is_big_endian():
|
|
68
|
+
assert crc16_kermit.to_bytes(0x1234) == b"\x12\x34"
|
|
69
|
+
assert crc32_iso.to_bytes(0x01020304) == b"\x01\x02\x03\x04"
|
|
70
|
+
|
|
71
|
+
def to_int_rejects_unsupported_width():
|
|
72
|
+
odd = CRC(8, 0x07, 0x00, False, False, 0x00, False)
|
|
73
|
+
odd.width = 24 # force an unsupported width past the constructor
|
|
74
|
+
with pytest.raises(ValueError):
|
|
75
|
+
odd.to_int(b"\x00\x00\x00")
|
|
76
|
+
|
|
77
|
+
#--------------------------------------------------------------------------------------- encode
|
|
78
|
+
|
|
79
|
+
def encode_appends_crc_after_message():
|
|
80
|
+
assert crc16_modbus.encode(b"hello") == b"hello\xf64"
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize("crc, n", [(crc8_smbus, 1), (crc16_kermit, 2), (crc32_iso, 4)])
|
|
83
|
+
def encode_keeps_message_and_adds_width_bytes(crc, n):
|
|
84
|
+
msg = b"payload"
|
|
85
|
+
frame = crc.encode(msg)
|
|
86
|
+
assert frame[:len(msg)] == msg
|
|
87
|
+
assert len(frame) == len(msg) + n
|
|
88
|
+
|
|
89
|
+
#--------------------------------------------------------------------------------------- decode
|
|
90
|
+
|
|
91
|
+
@pytest.mark.parametrize("crc", [crc8_smbus, crc16_modbus, crc16_kermit, crc32_iso])
|
|
92
|
+
def decode_recovers_encoded_message(crc):
|
|
93
|
+
for msg in (b"", b"x", b"Hello!", bytes(range(64))):
|
|
94
|
+
assert crc.decode(crc.encode(msg)) == msg
|
|
95
|
+
|
|
96
|
+
def decode_rejects_corrupted_payload():
|
|
97
|
+
frame = crc16_modbus.encode(b"important")
|
|
98
|
+
corrupt = bytes([frame[0] ^ 0xFF]) + frame[1:]
|
|
99
|
+
assert crc16_modbus.decode(corrupt) is None
|
|
100
|
+
|
|
101
|
+
def decode_rejects_corrupted_crc():
|
|
102
|
+
frame = crc16_modbus.encode(b"important")
|
|
103
|
+
assert crc16_modbus.decode(frame[:-1] + bytes([frame[-1] ^ 0xFF])) is None
|
|
104
|
+
|
|
105
|
+
@pytest.mark.parametrize("crc", [crc8_smbus, crc16_kermit, crc32_iso])
|
|
106
|
+
def decode_rejects_frame_shorter_than_crc(crc):
|
|
107
|
+
assert crc.decode(b"") is None
|
|
108
|
+
assert crc.decode(b"\x00" * (crc.width // 8 - 1)) is None
|
|
109
|
+
|
|
110
|
+
#---------------------------------------------------------------------------- parameter effects
|
|
111
|
+
|
|
112
|
+
def reflect_in_out_change_the_result():
|
|
113
|
+
plain = CRC(16, 0x8005, 0x0000, False, False, 0x0000, False)
|
|
114
|
+
reflected = CRC(16, 0x8005, 0x0000, True, True, 0x0000, False)
|
|
115
|
+
assert plain.checksum(CHECK) != reflected.checksum(CHECK)
|
|
116
|
+
|
|
117
|
+
def invert_out_byte_swaps_the_crc():
|
|
118
|
+
# crc16_modbus differs from the same spec without invertOut only by byte order
|
|
119
|
+
straight = CRC(16, 0x8005, 0xFFFF, True, True, 0x0000, False)
|
|
120
|
+
assert crc16_modbus.checksum(CHECK) == straight.checksum(CHECK) << 8 & 0xFF00 \
|
|
121
|
+
| straight.checksum(CHECK) >> 8
|
|
122
|
+
|
|
123
|
+
def xor_mask_flips_output():
|
|
124
|
+
no_xor = CRC(16, 0x1021, 0x0000, True, True, 0x0000, False)
|
|
125
|
+
with_xor = CRC(16, 0x1021, 0x0000, True, True, 0xFFFF, False)
|
|
126
|
+
assert with_xor.checksum(CHECK) == no_xor.checksum(CHECK) ^ 0xFFFF
|
|
127
|
+
|
|
128
|
+
def custom_crc_from_docstring_example():
|
|
129
|
+
crc = CRC(16, 0x8005, 0xFFFF, True, True, 0x0000, False)
|
|
130
|
+
assert crc.checksum(b"123456789") == 0x4B37
|
|
131
|
+
|
|
132
|
+
#--------------------------------------------------------------------------- real-world vectors
|
|
133
|
+
|
|
134
|
+
@pytest.mark.parametrize("data", [b"", b"The quick brown fox", bytes(range(256))])
|
|
135
|
+
def crc32_iso_equals_zlib(data):
|
|
136
|
+
# crc32_iso is the standard CRC-32 - it must agree with the stdlib bit-for-bit
|
|
137
|
+
assert crc32_iso.checksum(data) == zlib.crc32(data)
|
|
138
|
+
|
|
139
|
+
def modbus_rtu_request_frame():
|
|
140
|
+
# "read 10 holding registers from slave 1" - the canonical Modbus textbook frame
|
|
141
|
+
request = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x0A])
|
|
142
|
+
assert crc16_modbus.encode(request).hex(" ") == "01 03 00 00 00 0a c5 cd"
|
|
143
|
+
|
|
144
|
+
def one_wire_rom_checksums_to_zero():
|
|
145
|
+
# 1-Wire trick: a frame followed by its CRC-8/Maxim re-checksums back to 0
|
|
146
|
+
rom = bytes([0x28, 0xFF, 0x64, 0x1E, 0x0F, 0x9A, 0x6B]) # DS18B20-style ROM code
|
|
147
|
+
assert crc8_maxim.checksum(crc8_maxim.encode(rom)) == 0
|
|
148
|
+
|
|
149
|
+
@pytest.mark.parametrize("crc", [crc8_smbus, crc16_modbus, crc32_iso])
|
|
150
|
+
def every_single_bit_flip_is_detected(crc):
|
|
151
|
+
# the core promise of a CRC: any one-bit error in the frame fails verification
|
|
152
|
+
frame = crc.encode(b"sensor")
|
|
153
|
+
for i in range(len(frame)):
|
|
154
|
+
for bit in range(8):
|
|
155
|
+
broken = bytearray(frame)
|
|
156
|
+
broken[i] ^= (1 << bit)
|
|
157
|
+
assert crc.decode(bytes(broken)) is None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# tests/test_cstruct.py
|
|
2
|
+
|
|
3
|
+
"""Binary (de)serialization: round-trips, transforms, framing, regressions."""
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from xaeian.cstruct import Struct, Field, Type, Bitfield, Variant, Endian, Frame
|
|
7
|
+
from xaeian.crc import crc32_iso
|
|
8
|
+
|
|
9
|
+
def scalars():
|
|
10
|
+
s = Struct(name="scalars").add(
|
|
11
|
+
Field(Type.uint8, "a"), Field(Type.int16, "b"), Field(Type.uint32, "c"),
|
|
12
|
+
)
|
|
13
|
+
data = {"a": 7, "b": -1234, "c": 100_000}
|
|
14
|
+
assert s.decode(s.encode(data)) == data
|
|
15
|
+
|
|
16
|
+
def decode_returns_dict_for_one_list_for_many():
|
|
17
|
+
s = Struct(name="rec").add(Field(Type.uint8, "n"))
|
|
18
|
+
assert s.decode(s.encode({"n": 1})) == {"n": 1}
|
|
19
|
+
assert s.decode(s.encode([{"n": 1}, {"n": 2}])) == [{"n": 1}, {"n": 2}]
|
|
20
|
+
|
|
21
|
+
def floats_keep_value():
|
|
22
|
+
s = Struct(name="floats").add(Field(Type.float, "x"), Field(Type.double, "y"))
|
|
23
|
+
data = {"x": 1.5, "y": 0.25} # exact in IEEE-754, no rounding slack needed
|
|
24
|
+
assert s.decode(s.encode(data)) == data
|
|
25
|
+
|
|
26
|
+
def scale_stores_fixed_point():
|
|
27
|
+
s = Struct(name="scaled").add(Field(Type.uint16, "mv", scale=1000))
|
|
28
|
+
assert s.encode({"mv": 1.5}) == (1500).to_bytes(2, "little")
|
|
29
|
+
assert s.decode(s.encode({"mv": 1.5})) == {"mv": 1.5}
|
|
30
|
+
|
|
31
|
+
def array_field():
|
|
32
|
+
s = Struct(name="arr").add(Field(Type.uint8, "xs", length=4))
|
|
33
|
+
data = {"xs": [10, 20, 30, 40]}
|
|
34
|
+
assert s.decode(s.encode(data)) == data
|
|
35
|
+
|
|
36
|
+
def string_and_bytes():
|
|
37
|
+
s = Struct(name="blob").add(Field(Type.string, "name"), Field(Type.bytes, "data"))
|
|
38
|
+
data = {"name": "xaeian", "data": b"\x01\x02\x03"}
|
|
39
|
+
assert s.decode(s.encode(data)) == data
|
|
40
|
+
|
|
41
|
+
def string_utf8_roundtrip():
|
|
42
|
+
s = Struct(name="txt").add(Field(Type.string, "t"))
|
|
43
|
+
data = {"t": "żółw → OK"}
|
|
44
|
+
assert s.decode(s.encode(data)) == data
|
|
45
|
+
|
|
46
|
+
def crc_frame_roundtrip_single_and_multi():
|
|
47
|
+
s = Struct(name="cf", crc_frame=crc32_iso).add(Field(Type.uint16, "x"))
|
|
48
|
+
assert s.decode(s.encode({"x": 7})) == {"x": 7}
|
|
49
|
+
assert s.decode(s.encode([{"x": 1}, {"x": 2}])) == [{"x": 1}, {"x": 2}]
|
|
50
|
+
|
|
51
|
+
def crc_frame_rejects_corruption():
|
|
52
|
+
s = Struct(name="cfbad", crc_frame=crc32_iso).add(Field(Type.uint16, "x"))
|
|
53
|
+
bad = bytearray(s.encode({"x": 7}))
|
|
54
|
+
bad[-1] ^= 0xFF
|
|
55
|
+
with pytest.raises(ValueError):
|
|
56
|
+
s.decode(bytes(bad))
|
|
57
|
+
|
|
58
|
+
def bitfield():
|
|
59
|
+
s = Struct(name="flags").add(Bitfield("st", [("on", 1), ("err", 1), ("mode", 6)]))
|
|
60
|
+
data = {"st": {"on": 1, "err": 0, "mode": 42}}
|
|
61
|
+
assert s.decode(s.encode(data)) == data
|
|
62
|
+
|
|
63
|
+
def optional_field_uses_default():
|
|
64
|
+
s = Struct(name="opt").add(
|
|
65
|
+
Field(Type.uint8, "a"), Field(Type.uint8, "b", optional=True, default=9),
|
|
66
|
+
)
|
|
67
|
+
assert s.decode(s.encode({"a": 1})) == {"a": 1, "b": 9}
|
|
68
|
+
|
|
69
|
+
@pytest.mark.parametrize("endian, order", [(Endian.little, "little"), (Endian.big, "big")])
|
|
70
|
+
def endian_mirrors_bytes(endian, order):
|
|
71
|
+
s = Struct(name=f"e_{order}", endian=endian).add(Field(Type.uint16, "n"))
|
|
72
|
+
assert s.encode({"n": 258}) == (258).to_bytes(2, order) # 258 == 0x0102
|
|
73
|
+
assert s.decode(s.encode({"n": 258})) == {"n": 258}
|
|
74
|
+
|
|
75
|
+
@pytest.mark.parametrize("kind, body", [(0, {"a": 99}), (1, {"b": 1.5, "c": 2.5})])
|
|
76
|
+
def variant_selects_layout(kind, body):
|
|
77
|
+
s = Struct(name="msg").add(
|
|
78
|
+
Field(Type.uint8, "kind"),
|
|
79
|
+
Variant("body", "kind", {
|
|
80
|
+
0: [Field(Type.uint32, "a")],
|
|
81
|
+
1: [Field(Type.float, "b"), Field(Type.float, "c")],
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
msg = {"kind": kind, "body": body}
|
|
85
|
+
assert s.decode(s.encode(msg)) == msg
|
|
86
|
+
|
|
87
|
+
@pytest.mark.usefixtures("registry")
|
|
88
|
+
def frame_routes_by_code():
|
|
89
|
+
pos = Struct(code=1, name="pos").add(Field(Type.int16, "x"), Field(Type.int16, "y"))
|
|
90
|
+
temp = Struct(code=2, name="temp").add(Field(Type.float, "t"))
|
|
91
|
+
frame = Frame(pos, temp)
|
|
92
|
+
data = {"pos": {"x": -3, "y": 7}, "temp": {"t": 21.5}}
|
|
93
|
+
assert frame.decode(frame.encode(data)) == data
|
|
94
|
+
|
|
95
|
+
def alignment_padding_roundtrips_each_record():
|
|
96
|
+
# regression: decode must consume the padding encode appends after each record
|
|
97
|
+
s = Struct(name="aligned", align=4).add(Field(Type.uint8, "a"), Field(Type.uint16, "b"))
|
|
98
|
+
recs = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
|
99
|
+
assert s.decode(s.encode(recs)) == recs
|
|
100
|
+
|
|
101
|
+
def crc_detects_corruption():
|
|
102
|
+
s = Struct(name="guarded", crc=crc32_iso).add(Field(Type.uint16, "n"))
|
|
103
|
+
frame = s.encode({"n": 0xBEEF})
|
|
104
|
+
assert s.decode(frame) == {"n": 0xBEEF}
|
|
105
|
+
corrupt = bytes([frame[0] ^ 0xFF]) + frame[1:]
|
|
106
|
+
with pytest.raises(ValueError):
|
|
107
|
+
s.decode(corrupt)
|
|
108
|
+
|
|
109
|
+
def missing_required_field_raises():
|
|
110
|
+
s = Struct(name="req").add(Field(Type.uint8, "a"))
|
|
111
|
+
with pytest.raises(KeyError):
|
|
112
|
+
s.encode({})
|
|
113
|
+
|
|
114
|
+
@pytest.fixture
|
|
115
|
+
def registry():
|
|
116
|
+
saved = dict(Struct._codes)
|
|
117
|
+
Struct._codes.clear()
|
|
118
|
+
yield
|
|
119
|
+
Struct._codes.clear()
|
|
120
|
+
Struct._codes.update(saved)
|
|
121
|
+
|
|
122
|
+
@pytest.mark.usefixtures("registry")
|
|
123
|
+
def code_registry_is_idempotent():
|
|
124
|
+
Struct(code=0xA1, name="Frame")
|
|
125
|
+
Struct(code=0xA1, name="Frame") # same code + name: allowed
|
|
126
|
+
with pytest.raises(ValueError):
|
|
127
|
+
Struct(code=0xA1, name="Other") # same code, new name: rejected
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# tests/test_db_kv_common.py
|
|
2
|
+
|
|
3
|
+
"""KeyValue store internals: key/table validation, canonical JSON, SQL builders."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from xaeian.db.kv_common import (
|
|
10
|
+
check_key, check_table, dumps, loads, now_ms,
|
|
11
|
+
KEY_MAX, VALUE_MAX_BYTES,
|
|
12
|
+
sql_create, sql_get_value, sql_get_meta, sql_read_all, sql_read_all_meta, where_key,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
#------------------------------------------------------------------------------------ check_key
|
|
16
|
+
|
|
17
|
+
@pytest.mark.parametrize("key", ["a", "config.timeout", "x" * KEY_MAX])
|
|
18
|
+
def check_key_accepts_reasonable_keys(key):
|
|
19
|
+
check_key(key) # no exception == pass
|
|
20
|
+
|
|
21
|
+
def check_key_rejects_non_string():
|
|
22
|
+
with pytest.raises(TypeError):
|
|
23
|
+
check_key(123)
|
|
24
|
+
|
|
25
|
+
def check_key_rejects_empty():
|
|
26
|
+
with pytest.raises(ValueError):
|
|
27
|
+
check_key("")
|
|
28
|
+
|
|
29
|
+
def check_key_rejects_overlong():
|
|
30
|
+
with pytest.raises(ValueError):
|
|
31
|
+
check_key("x" * (KEY_MAX + 1))
|
|
32
|
+
|
|
33
|
+
#---------------------------------------------------------------------------------- check_table
|
|
34
|
+
|
|
35
|
+
@pytest.mark.parametrize("table", ["kv", "_store", "Cache1"])
|
|
36
|
+
def check_table_accepts_valid_identifiers(table):
|
|
37
|
+
check_table(table)
|
|
38
|
+
|
|
39
|
+
def check_table_rejects_non_string():
|
|
40
|
+
with pytest.raises(TypeError):
|
|
41
|
+
check_table(None)
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize("table", ["", "1table", "drop table", "a-b", "a;b"])
|
|
44
|
+
def check_table_rejects_bad_identifiers(table):
|
|
45
|
+
# stricter than keys: table names are interpolated into DDL, so no leading digit / symbols
|
|
46
|
+
with pytest.raises(ValueError):
|
|
47
|
+
check_table(table)
|
|
48
|
+
|
|
49
|
+
def check_table_rejects_overlong():
|
|
50
|
+
with pytest.raises(ValueError):
|
|
51
|
+
check_table("t" * (KEY_MAX + 1))
|
|
52
|
+
|
|
53
|
+
#---------------------------------------------------------------------------------------- dumps
|
|
54
|
+
|
|
55
|
+
def dumps_is_canonical_sorted_and_compact():
|
|
56
|
+
assert dumps({"b": 1, "a": 2}) == '{"a":2,"b":1}'
|
|
57
|
+
|
|
58
|
+
def dumps_preserves_unicode():
|
|
59
|
+
text = dumps("zażółć")
|
|
60
|
+
assert "\\u" not in text # not escaped
|
|
61
|
+
assert json.loads(text) == "zażółć"
|
|
62
|
+
|
|
63
|
+
def dumps_rejects_oversized_values():
|
|
64
|
+
with pytest.raises(ValueError):
|
|
65
|
+
dumps("x" * (VALUE_MAX_BYTES + 1))
|
|
66
|
+
|
|
67
|
+
def dumps_rejects_non_json_types():
|
|
68
|
+
with pytest.raises(TypeError):
|
|
69
|
+
dumps({1, 2, 3}) # a set is not JSON-serializable
|
|
70
|
+
|
|
71
|
+
#---------------------------------------------------------------------------------------- loads
|
|
72
|
+
|
|
73
|
+
def loads_round_trips_dumps():
|
|
74
|
+
for value in [None, True, 42, 1.5, "text", [1, 2], {"a": 1}]:
|
|
75
|
+
assert loads(dumps(value), "k") == value
|
|
76
|
+
|
|
77
|
+
def loads_rejects_non_string_row():
|
|
78
|
+
with pytest.raises(ValueError):
|
|
79
|
+
loads(123, "mykey")
|
|
80
|
+
|
|
81
|
+
def loads_wraps_bad_json_with_key_context():
|
|
82
|
+
with pytest.raises(ValueError, match="mykey"):
|
|
83
|
+
loads("{not json}", "mykey")
|
|
84
|
+
|
|
85
|
+
#--------------------------------------------------------------------------------------- now_ms
|
|
86
|
+
|
|
87
|
+
def now_ms_returns_epoch_milliseconds():
|
|
88
|
+
ts = now_ms()
|
|
89
|
+
assert isinstance(ts, int)
|
|
90
|
+
# bracket against the wall clock: catches a seconds (too small) or nanoseconds mixup
|
|
91
|
+
assert time.time() * 1000 - 5000 < ts < time.time() * 1000 + 5000
|
|
92
|
+
|
|
93
|
+
#--------------------------------------------------------------------------------- SQL builders
|
|
94
|
+
|
|
95
|
+
def sql_create_declares_kv_schema():
|
|
96
|
+
assert sql_create("kv") == (
|
|
97
|
+
"CREATE TABLE IF NOT EXISTS kv ("
|
|
98
|
+
f"key VARCHAR({KEY_MAX}) PRIMARY KEY, "
|
|
99
|
+
"value TEXT NOT NULL, "
|
|
100
|
+
"updated_at INTEGER NOT NULL)"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def sql_get_value_selects_single_value():
|
|
104
|
+
assert sql_get_value("kv", "?") == "SELECT value FROM kv WHERE key = ?"
|
|
105
|
+
|
|
106
|
+
def sql_get_meta_selects_value_and_timestamp():
|
|
107
|
+
assert sql_get_meta("kv", "$1") == "SELECT value, updated_at FROM kv WHERE key = $1"
|
|
108
|
+
|
|
109
|
+
def sql_read_all_is_ordered_by_key():
|
|
110
|
+
assert sql_read_all("kv") == "SELECT key, value FROM kv ORDER BY key"
|
|
111
|
+
|
|
112
|
+
def sql_read_all_meta_includes_timestamp():
|
|
113
|
+
assert sql_read_all_meta("kv") == "SELECT key, value, updated_at FROM kv ORDER BY key"
|
|
114
|
+
|
|
115
|
+
def where_key_builds_lookup_clause():
|
|
116
|
+
assert where_key("%s") == "key = %s"
|
|
117
|
+
|
|
118
|
+
@pytest.mark.parametrize("build", [
|
|
119
|
+
lambda t: sql_create(t),
|
|
120
|
+
lambda t: sql_get_value(t, "?"),
|
|
121
|
+
lambda t: sql_get_meta(t, "?"),
|
|
122
|
+
lambda t: sql_read_all(t),
|
|
123
|
+
lambda t: sql_read_all_meta(t),
|
|
124
|
+
], ids=["create", "get_value", "get_meta", "read_all", "read_all_meta"])
|
|
125
|
+
def sql_builders_reject_bad_table_names(build):
|
|
126
|
+
# every table name flows through ident(), so injection attempts blow up at build time
|
|
127
|
+
with pytest.raises(ValueError):
|
|
128
|
+
build("kv; DROP TABLE kv")
|