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.
Files changed (88) hide show
  1. {xaeian-0.7.2 → xaeian-0.7.4}/PKG-INFO +15 -5
  2. {xaeian-0.7.2 → xaeian-0.7.4}/pyproject.toml +9 -2
  3. {xaeian-0.7.2 → xaeian-0.7.4}/readme.md +5 -4
  4. xaeian-0.7.4/tests/test_colors.py +17 -0
  5. xaeian-0.7.4/tests/test_crc.py +157 -0
  6. xaeian-0.7.4/tests/test_cstruct.py +127 -0
  7. xaeian-0.7.4/tests/test_db_kv_common.py +128 -0
  8. xaeian-0.7.4/tests/test_db_sqlite.py +221 -0
  9. xaeian-0.7.4/tests/test_db_utils.py +191 -0
  10. xaeian-0.7.4/tests/test_files.py +293 -0
  11. xaeian-0.7.4/tests/test_files_json_smart.py +101 -0
  12. xaeian-0.7.4/tests/test_files_path.py +140 -0
  13. xaeian-0.7.4/tests/test_log.py +90 -0
  14. xaeian-0.7.4/tests/test_table.py +151 -0
  15. xaeian-0.7.4/tests/test_xstring.py +106 -0
  16. xaeian-0.7.4/tests/test_xtime.py +165 -0
  17. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/__init__.py +5 -2
  18. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/__main__.py +10 -5
  19. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cli/_args.py +7 -3
  20. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cli/dupes.py +2 -2
  21. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cli/fonts.py +15 -16
  22. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cli/tree.py +2 -2
  23. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cli/wifi.py +2 -2
  24. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cmd.py +2 -2
  25. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/colors.py +2 -0
  26. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/crc.py +4 -5
  27. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cstruct.py +8 -7
  28. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/__init__.py +6 -4
  29. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/abstract.py +1 -2
  30. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/abstract_async.py +5 -4
  31. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/kv.py +5 -4
  32. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/kv_async.py +5 -4
  33. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/kv_common.py +6 -6
  34. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/mysql.py +4 -10
  35. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/mysql_async.py +6 -14
  36. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/postgres.py +3 -10
  37. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/postgres_async.py +21 -15
  38. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/sqlite.py +4 -11
  39. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/sqlite_async.py +6 -15
  40. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/utils.py +26 -1
  41. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/dsp.py +23 -23
  42. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/__init__.py +2 -2
  43. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/ee.py +5 -5
  44. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/fp.py +14 -12
  45. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/kicad.py +14 -12
  46. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/kicad_clean.py +14 -16
  47. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/spice.py +19 -19
  48. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/eda/sym.py +15 -15
  49. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/__init__.py +2 -2
  50. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/bound.py +0 -4
  51. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/config.py +1 -8
  52. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/csv.py +2 -2
  53. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/dir.py +1 -1
  54. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/ini.py +12 -5
  55. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/json.py +1 -1
  56. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/yaml.py +18 -9
  57. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files_async.py +14 -3
  58. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/log.py +15 -15
  59. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/__init__.py +1 -1
  60. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/ico.py +7 -6
  61. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/img.py +9 -6
  62. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/meta.py +2 -2
  63. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/min.py +4 -4
  64. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/pdf.py +5 -5
  65. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/media/utils.py +1 -1
  66. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/net/ftp.py +35 -23
  67. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/net/sftp.py +38 -38
  68. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/plot.py +148 -149
  69. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/serial/__init__.py +2 -0
  70. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/serial/port.py +5 -3
  71. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/serial/rec.py +3 -1
  72. xaeian-0.7.2/xaeian/serial/rec-app.py → xaeian-0.7.4/xaeian/serial/rec_app.py +3 -1
  73. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/serial/sh.py +7 -7
  74. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/table.py +7 -7
  75. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/xstring.py +15 -4
  76. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/xtime.py +11 -9
  77. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian.egg-info/PKG-INFO +15 -5
  78. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian.egg-info/SOURCES.txt +14 -1
  79. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian.egg-info/requires.txt +12 -0
  80. {xaeian-0.7.2 → xaeian-0.7.4}/setup.cfg +0 -0
  81. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/cli/__init__.py +0 -0
  82. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/db/errors.py +0 -0
  83. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/file.py +0 -0
  84. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/files/path.py +0 -0
  85. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian/net/__init__.py +0 -0
  86. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian.egg-info/dependency_links.txt +0 -0
  87. {xaeian-0.7.2 → xaeian-0.7.4}/xaeian.egg-info/entry_points.txt +0 -0
  88. {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.2
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 + 1Hz CSV append
125
- from xaeian.serial import Recorder, RecorderPool
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
- pool = RecorderPool(recs, period_ms=1000, csv_path="series.csv")
129
- pool.start(); ...; pool.stop()
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.2"
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
- all = ["Pillow", "PyMuPDF", "aiomysql", "aiosqlite", "asyncpg", "matplotlib", "numpy", "psycopg2-binary", "pymysql", "pypdf", "pytz", "scipy", "sexpdata", "tzlocal"]
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 + 1Hz CSV append
75
- from xaeian.serial import Recorder, RecorderPool
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
- pool = RecorderPool(recs, period_ms=1000, csv_path="series.csv")
79
- pool.start(); ...; pool.stop()
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")