sltcore 0.1.0__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.
@@ -0,0 +1,3 @@
1
+ # These are supported funding model platforms
2
+
3
+ github: fangface-hub
@@ -0,0 +1,25 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ release:
6
+ types: [published]
7
+
8
+ jobs:
9
+ build-and-publish:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - name: Install uv
16
+ uses: astral-sh/setup-uv@v3
17
+
18
+ - name: Build wheel
19
+ run: uv build
20
+
21
+ - name: Publish to PyPI
22
+ uses: pypa/gh-action-pypi-publish@release/v1
23
+ with:
24
+ password: ${{ secrets.PYPI_API_TOKEN }}
25
+ packages_dir: dist
@@ -0,0 +1,26 @@
1
+ name: Publish to TestPyPI
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ release:
6
+ types: [published]
7
+
8
+ jobs:
9
+ build-and-publish:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - name: Install uv
16
+ uses: astral-sh/setup-uv@v3
17
+
18
+ - name: Build wheel
19
+ run: uv build
20
+
21
+ - name: Publish to TestPyPI
22
+ uses: pypa/gh-action-pypi-publish@release/v1
23
+ with:
24
+ password: ${{ secrets.TEST_PYPI_API_TOKEN }}
25
+ repository-url: https://test.pypi.org/legacy/
26
+ packages_dir: dist
@@ -0,0 +1,218 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,23 @@
1
+ {
2
+ "version": "0.2.0",
3
+ "configurations": [
4
+ {
5
+ "name": "Debug Pytest (single test)",
6
+ "type": "python",
7
+ "request": "launch",
8
+ "module": "pytest",
9
+ "args": [
10
+ "-k", "${input:test_name}"
11
+ ],
12
+ "console": "integratedTerminal"
13
+ }
14
+ ],
15
+ "inputs": [
16
+ {
17
+ "id": "test_name",
18
+ "type": "promptString",
19
+ "description": "Enter pytest -k expression",
20
+ "default": "test_bits_get_cross_byte"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "python.defaultInterpreterPath": "${workspaceFolder}/.venv/Scripts/python.exe"
3
+ }
sltcore-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fangface
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.
sltcore-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: sltcore
3
+ Version: 0.1.0
4
+ Summary: A toolkit for handling structured data layouts and bit-level operations.
5
+ Author: fangface
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+
10
+ # StructLayoutToolkitCore
11
+
12
+ stlcore is a minimal, high‑cohesion core library for bit‑accurate binary slicing. It provides the fundamental primitives (bits_get, bits_set, InfoSize) required to build struct layout analyzers, binary editors, protocol inspectors, and low‑level testing tools. Designed for clarity, correctness, and composability.
@@ -0,0 +1,3 @@
1
+ # StructLayoutToolkitCore
2
+
3
+ stlcore is a minimal, high‑cohesion core library for bit‑accurate binary slicing. It provides the fundamental primitives (bits_get, bits_set, InfoSize) required to build struct layout analyzers, binary editors, protocol inspectors, and low‑level testing tools. Designed for clarity, correctness, and composability.
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "sltcore"
3
+ version = "0.1.0"
4
+ description = "A toolkit for handling structured data layouts and bit-level operations."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "fangface" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = []
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["sltcore"]
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest>=9.1.1",
22
+ ]
23
+
24
+ [tool.pytest.ini_options]
25
+ pythonpath = ["src"]
26
+ testpaths = ["tests"]
27
+
28
+ [[tool.uv.index]]
29
+ name = "testpypi"
30
+ url = "https://test.pypi.org/simple/"
31
+ publish-url = "https://test.pypi.org/legacy/"
32
+ explicit = true
File without changes
@@ -0,0 +1,88 @@
1
+ """Bit-level operations for structured data layouts."""
2
+ from sltcore.types import Info, InfoSize
3
+
4
+
5
+ def bits_get(buf: bytearray, offset: InfoSize, size: InfoSize) -> Info:
6
+ """ Extracts a slice of bits from the given bytearray
7
+ starting at the specified bit offset and spanning
8
+ the specified bit size. Returns an Info object containing
9
+ the extracted raw value and an InfoSize representing
10
+ the size of the extracted data.
11
+ Parameters:
12
+ - buf (bytearray): The source bytearray to extract bits from.
13
+ - offset (InfoSize): The bit offset from the start of buf to begin
14
+ extraction.
15
+ - size (InfoSize): The number of bits to extract.
16
+
17
+ Returns:
18
+ - Info: An Info object where raw_value contains the extracted bits as an
19
+ integer, and info_size indicates the size of the extracted data.
20
+
21
+ Notes:
22
+ - The extraction returns the bits as an integer in the raw_value
23
+ field of the Info object.
24
+ - Only the necessary byte range is extracted.
25
+ - Leading and trailing unwanted bits are masked out.
26
+ - The returned Info object's raw_value is front-packed, meaning
27
+ the first bit of the extracted range aligns with the most
28
+ significant bit of raw_value.
29
+ """
30
+ # Correct byte span (handles cross-byte bit ranges)
31
+ need = _required_bytes_for_extraction(offset, size)
32
+
33
+ # Extract bytes
34
+ chunk = buf[offset.byte:offset.byte + need]
35
+ value = int.from_bytes(chunk, "big")
36
+
37
+ # Front-pack
38
+ shift = (need * 8) - size.bits - offset.bit
39
+ value >>= shift
40
+
41
+ # Mask to exact bit length
42
+ value &= (1 << size.bits) - 1
43
+
44
+ return Info(raw_value=value, info_size=size)
45
+
46
+
47
+ def bits_set(buf: bytearray, offset: InfoSize, size: InfoSize,
48
+ value: int) -> None:
49
+ """
50
+ Set a bit slice in `buf` starting at `offset` and spanning `size`.
51
+ `value` is an integer whose lower `size.bits` bits are written.
52
+ """
53
+
54
+ # Correct byte span
55
+ need = _required_bytes_for_extraction(offset, size)
56
+
57
+ start = offset.byte
58
+ end = start + need
59
+
60
+ # Read existing bytes
61
+ chunk = int.from_bytes(buf[start:end], "big")
62
+
63
+ # Compute shift
64
+ shift = (need * 8) - size.bits - offset.bit
65
+
66
+ # Build mask
67
+ mask = ((1 << size.bits) - 1) << shift
68
+
69
+ # Clear target field and insert new value
70
+ chunk = (chunk & ~mask) | ((value << shift) & mask)
71
+
72
+ # Write back
73
+ buf[start:end] = chunk.to_bytes(need, "big")
74
+
75
+
76
+ def _required_bytes_for_extraction(offset: InfoSize, size: InfoSize) -> int:
77
+ """
78
+ Calculates the number of bytes required to extract a bit slice
79
+ starting at the given offset and spanning the given size.
80
+
81
+ Parameters:
82
+ - offset (InfoSize): The bit offset from the start of the buffer.
83
+ - size (InfoSize): The number of bits to extract.
84
+
85
+ Returns:
86
+ - int: The number of bytes required to extract the specified bit slice.
87
+ """
88
+ return (offset.bit + size.bits + 7) // 8
@@ -0,0 +1,158 @@
1
+ """Type definitions for structured data layouts and bit-level operations."""
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class InfoSize:
7
+ """Represents a size in bytes and bits."""
8
+ byte: int = 0
9
+ bit: int = 0
10
+
11
+ def __post_init__(self):
12
+ """Ensures that the bit count is normalized to be less than 8,
13
+ and adjusts the byte count accordingly."""
14
+ extra_bytes, new_bits = divmod(self.bit, 8)
15
+ object.__setattr__(self, "byte", self.byte + extra_bytes)
16
+ object.__setattr__(self, "bit", new_bits)
17
+
18
+ @property
19
+ def bits(self) -> int:
20
+ """Returns the total size in bits."""
21
+ return self.byte * 8 + self.bit
22
+
23
+ @property
24
+ def bytes(self) -> int:
25
+ """Returns the total size in bytes,
26
+ rounding up if there are leftover bits."""
27
+ return (self.bits + 7) // 8 # round up
28
+
29
+ @property
30
+ def hex_digits(self) -> int:
31
+ """Returns the number of hexadecimal digits
32
+ needed to represent the size in bits."""
33
+ return self.bits // 4
34
+
35
+ @property
36
+ def mask(self) -> int:
37
+ """Returns a bitmask corresponding to the size in bits."""
38
+ return (1 << self.bits) - 1
39
+
40
+ def __add__(self, other: "InfoSize") -> "InfoSize":
41
+ """ Adds two InfoSize instances together, returning a new InfoSize
42
+ with the combined byte and bit counts, normalized to ensure
43
+ the bit count is less than 8."""
44
+ return InfoSize(self.byte + other.byte, self.bit + other.bit)
45
+
46
+ def __sub__(self, other: "InfoSize") -> "InfoSize":
47
+ """ Subtracts one InfoSize from another, returning a new InfoSize
48
+ with the resulting byte and bit counts, normalized to ensure
49
+ the bit count is less than 8. Raises ValueError if the result
50
+ would be negative."""
51
+ total_bits = self.bits - other.bits
52
+ if total_bits < 0:
53
+ raise ValueError("InfoSize subtraction resulted in negative size")
54
+ return InfoSize(0, total_bits)
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Info:
59
+ """Represents a slice of bits extracted from a bytearray,
60
+ along with its size information."""
61
+ raw_value: int
62
+ info_size: InfoSize
63
+
64
+ @property
65
+ def to_unsigned_int(self) -> int:
66
+ """Returns the extracted bits as an unsigned integer."""
67
+ return self.raw_value
68
+
69
+ @property
70
+ def to_signed_int(self) -> int:
71
+ """Returns the extracted bits as a signed integer."""
72
+ raw = self.to_unsigned_int
73
+ bits = self.info_size.bits
74
+ sign_bit = 1 << (bits - 1)
75
+ return (raw ^ sign_bit) - sign_bit
76
+
77
+ @property
78
+ def to_hex(self) -> str:
79
+ """Returns the extracted bits as a hexadecimal string."""
80
+ return hex(self.to_unsigned_int)
81
+
82
+ @property
83
+ def to_bytes(self) -> bytes:
84
+ """Returns the extracted bits as a bytes object."""
85
+ value = self.to_unsigned_int
86
+ byte_len = self.info_size.bytes
87
+ return value.to_bytes(byte_len, byteorder="big")
88
+
89
+ @property
90
+ def to_bool(self) -> bool:
91
+ """Returns the extracted bits as a boolean value."""
92
+ return bool(self.to_unsigned_int)
93
+
94
+ @property
95
+ def to_float(self) -> float:
96
+ """Returns the extracted bits as a float."""
97
+ raw_int = self.to_unsigned_int
98
+ return _float_from_bits(raw_int, self.info_size.bits)
99
+
100
+
101
+ def _float_from_bits(raw: int, bits: int) -> float:
102
+ """Converts a raw integer representation of a float
103
+ to a Python float, based on the specified bit size.
104
+ Supports 16, 32, and 64 bits.
105
+ """
106
+ if bits == 16:
107
+ """IEEE754 half-precision (16bit) → float32"""
108
+ s = (raw >> 15) & 0x0001
109
+ e = (raw >> 10) & 0x001F
110
+ f = raw & 0x03FF
111
+
112
+ if e == 0:
113
+ if f == 0:
114
+ return (-1)**s * 0.0
115
+ return (-1)**s * (f / 2**10) * 2**(-14)
116
+ elif e == 31:
117
+ if f == 0:
118
+ return float('inf') if s == 0 else float('-inf')
119
+ return float('nan')
120
+ else:
121
+ return (-1)**s * (1 + f / 2**10) * 2**(e - 15)
122
+ elif bits == 32:
123
+ # IEEE754 single precision
124
+ # High-speed version without using struct
125
+ s = (raw >> 31) & 0x1
126
+
127
+ e = (raw >> 23) & 0xFF
128
+ f = raw & 0x7FFFFF
129
+
130
+ if e == 0:
131
+ return (-1)**s * (f / 2**23) * 2**(-126)
132
+ elif e == 255:
133
+ if f == 0:
134
+ return float('-inf') if s else float('inf')
135
+ return float('nan')
136
+ else:
137
+ return (-1)**s * (1 + f / 2**23) * 2**(e - 127)
138
+
139
+ elif bits == 64:
140
+ # IEEE754 double precision
141
+ s = (raw >> 63) & 0x1
142
+ e = (raw >> 52) & 0x7FF
143
+ f = raw & 0xFFFFFFFFFFFFF
144
+
145
+ if e == 0:
146
+ return (-1)**s * (f / 2**52) * 2**(-1022)
147
+ elif e == 2047:
148
+ if f == 0:
149
+ return float('-inf') if s else float('inf')
150
+ return float('nan')
151
+ else:
152
+ return (-1)**s * (1 + f / 2**52) * 2**(e - 1023)
153
+
154
+ else:
155
+ raise ValueError(''.join([
156
+ f"Unsupported bit size for float conversion: {bits}. ",
157
+ "Only 16, 32, and 64 are supported."
158
+ ]))
@@ -0,0 +1,87 @@
1
+ """Tests for sltcore.bits."""
2
+ import pytest
3
+
4
+ from sltcore.bits import _required_bytes_for_extraction, bits_get, bits_set
5
+ from sltcore.types import InfoSize
6
+
7
+
8
+ def test_required_bytes_for_extraction():
9
+ """Test the _required_bytes_for_extraction function
10
+ with various scenarios."""
11
+ # offset.bit + size.bits < 8 → 1 byte
12
+ assert _required_bytes_for_extraction(InfoSize(0, 0), InfoSize(0, 7)) == 1
13
+
14
+ # Exactly 8 → 1 byte
15
+ assert _required_bytes_for_extraction(InfoSize(0, 0), InfoSize(0, 8)) == 1
16
+
17
+ # 9 → 2 bytes
18
+ assert _required_bytes_for_extraction(InfoSize(0, 0), InfoSize(0, 9)) == 2
19
+
20
+ # With offset.bit
21
+ assert _required_bytes_for_extraction(InfoSize(0, 3), InfoSize(0, 5)) == 1
22
+ assert _required_bytes_for_extraction(InfoSize(0, 6), InfoSize(0, 4)) == 2
23
+
24
+
25
+ def test_bits_get_single_byte():
26
+ """Test bits_get with a single byte buffer."""
27
+ buf = bytearray([0b11010110]) # 214
28
+ offset = InfoSize(0, 2) # bit offset = 2
29
+ size = InfoSize(0, 4) # 4 bits
30
+
31
+ info = bits_get(buf, offset, size)
32
+ assert info.raw_value == 0b0101 # 214 = 11010110 → offset=2 → 0101
33
+
34
+
35
+ def test_bits_get_cross_byte():
36
+ """Test bits_get with a buffer that spans multiple bytes."""
37
+ buf = bytearray([0b11110000, 0b00001111])
38
+ offset = InfoSize(0, 6) # From bit 6 of byte0
39
+ size = InfoSize(0, 6) # Extract 6 bits (spans into byte1)
40
+
41
+ info = bits_get(buf, offset, size)
42
+ # 11110000 00001111
43
+ # ^^ ^^^^
44
+ assert info.raw_value == 0b000000 # front-packed
45
+
46
+
47
+ def test_bits_set_single_byte():
48
+ """Test bits_set with a single byte buffer."""
49
+ buf = bytearray([0b11110000])
50
+ offset = InfoSize(0, 2)
51
+ size = InfoSize(0, 4)
52
+
53
+ bits_set(buf, offset, size, 0b0011)
54
+ # 11110000 → offset=2 → bits to change: 1100 → write 0011
55
+ assert buf[0] == 0b11001100
56
+
57
+
58
+ def test_bits_set_cross_byte():
59
+ """Test bits_set with a buffer that spans multiple bytes."""
60
+ buf = bytearray([0b11110000, 0b00001111])
61
+ offset = InfoSize(0, 6)
62
+ size = InfoSize(0, 6)
63
+
64
+ bits_set(buf, offset, size, 0b101010)
65
+
66
+ # Comparing with a manually calculated mask is fine, but
67
+ # confirming it matches by re-extracting with bits_get
68
+ # is more abstract and robust
69
+ info = bits_get(buf, offset, size)
70
+
71
+ assert info.raw_value == 0b101010
72
+
73
+
74
+ def test_bits_get_set_roundtrip():
75
+ """Test that bits_get followed by bits_set returns the original value."""
76
+ buf = bytearray([0xAA, 0xBB, 0xCC, 0xDD])
77
+
78
+ offset = InfoSize(0, 5)
79
+ size = InfoSize(0, 10)
80
+
81
+ # Verify original value is restored through get -> set cycle
82
+ original = bits_get(buf, offset, size).raw_value
83
+
84
+ bits_set(buf, offset, size, original)
85
+ after = bits_get(buf, offset, size).raw_value
86
+
87
+ assert original == after
@@ -0,0 +1,108 @@
1
+ """Tests for sltcore.types."""
2
+ import pytest
3
+
4
+ from sltcore.types import Info, InfoSize
5
+
6
+
7
+ def test_infosize_normalization():
8
+ """Test InfoSize normalization of bits to bytes."""
9
+ # 10 bits should become 1 byte and 2 bits
10
+ size = InfoSize(0, 10)
11
+ assert size.byte == 1
12
+ assert size.bit == 2
13
+
14
+ # 16 bits should become 2 bytes and 0 bits
15
+ size = InfoSize(1, 8)
16
+ assert size.byte == 2
17
+ assert size.bit == 0
18
+
19
+
20
+ def test_infosize_bits_property():
21
+ """Test the bits property of InfoSize."""
22
+ size = InfoSize(2, 3)
23
+ assert size.bits == 19 # 2 * 8 + 3
24
+
25
+
26
+ def test_infosize_bytes_property():
27
+ """Test the bytes property of InfoSize (rounding up)."""
28
+ assert InfoSize(1, 0).bytes == 1
29
+ assert InfoSize(1, 1).bytes == 2
30
+ assert InfoSize(1, 7).bytes == 2
31
+
32
+
33
+ def test_infosize_hex_digits():
34
+ """Test the hex_digits property."""
35
+ assert InfoSize(1, 0).hex_digits == 2 # 8 bits / 4
36
+ assert InfoSize(2, 0).hex_digits == 4 # 16 bits / 4
37
+
38
+
39
+ def test_infosize_mask():
40
+ """Test the mask property."""
41
+ assert InfoSize(0, 3).mask == 0b111
42
+ assert InfoSize(1, 0).mask == 0xFF
43
+
44
+
45
+ def test_infosize_arithmetic():
46
+ """Test addition and subtraction of InfoSize."""
47
+ s1 = InfoSize(1, 2)
48
+ s2 = InfoSize(0, 7)
49
+
50
+ # Addition
51
+ added = s1 + s2
52
+ assert added.bits == s1.bits + s2.bits
53
+
54
+ # Subtraction
55
+ subbed = s1 - s2
56
+ assert subbed.bits == s1.bits - s2.bits
57
+
58
+ with pytest.raises(ValueError):
59
+ s2 - s1
60
+
61
+
62
+ def test_info_to_signed_int():
63
+ """Test signed integer conversion."""
64
+ # 8-bit signed
65
+ info = Info(0xFF, InfoSize(1, 0))
66
+ assert info.to_signed_int == -1
67
+
68
+ info = Info(0x7F, InfoSize(1, 0))
69
+ assert info.to_signed_int == 127
70
+
71
+ # 4-bit signed
72
+ info = Info(0b1111, InfoSize(0, 4))
73
+ assert info.to_signed_int == -1
74
+
75
+ info = Info(0b0111, InfoSize(0, 4))
76
+ assert info.to_signed_int == 7
77
+
78
+
79
+ def test_info_to_bytes():
80
+ """Test conversion to bytes object."""
81
+ info = Info(0xAABB, InfoSize(2, 0))
82
+ assert info.to_bytes == b'\xaa\xbb'
83
+
84
+
85
+ def test_info_to_float_16():
86
+ """Test 16-bit float conversion."""
87
+ # 0.0
88
+ assert Info(0x0000, InfoSize(0, 16)).to_float == 0.0
89
+ # 1.0 (sign=0, exp=15, fraction=0) -> 0 01111 0000000000 -> 0x3C00
90
+ assert Info(0x3C00, InfoSize(0, 16)).to_float == 1.0
91
+
92
+
93
+ def test_info_to_float_32():
94
+ """Test 32-bit float conversion."""
95
+ # 1.0 (sign=0, exp=127, fraction=0) -> 0x3F800000
96
+ assert Info(0x3F800000, InfoSize(0, 32)).to_float == 1.0
97
+
98
+
99
+ def test_info_to_float_64():
100
+ """Test 64-bit float conversion."""
101
+ # 1.0 (sign=0, exp=1023, fraction=0) -> 0x3FF0000000000000
102
+ assert Info(0x3FF0000000000000, InfoSize(0, 64)).to_float == 1.0
103
+
104
+
105
+ def test_info_to_float_invalid_size():
106
+ """Test that invalid bit sizes for float conversion raise ValueError."""
107
+ with pytest.raises(ValueError):
108
+ Info(0, InfoSize(0, 10)).to_float
sltcore-0.1.0/uv.lock ADDED
@@ -0,0 +1,79 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "colorama"
7
+ version = "0.4.6"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "iniconfig"
16
+ version = "2.3.0"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
19
+ wheels = [
20
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
21
+ ]
22
+
23
+ [[package]]
24
+ name = "packaging"
25
+ version = "26.2"
26
+ source = { registry = "https://pypi.org/simple" }
27
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
28
+ wheels = [
29
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
30
+ ]
31
+
32
+ [[package]]
33
+ name = "pluggy"
34
+ version = "1.6.0"
35
+ source = { registry = "https://pypi.org/simple" }
36
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
37
+ wheels = [
38
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
39
+ ]
40
+
41
+ [[package]]
42
+ name = "pygments"
43
+ version = "2.20.0"
44
+ source = { registry = "https://pypi.org/simple" }
45
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
46
+ wheels = [
47
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
48
+ ]
49
+
50
+ [[package]]
51
+ name = "pytest"
52
+ version = "9.1.1"
53
+ source = { registry = "https://pypi.org/simple" }
54
+ dependencies = [
55
+ { name = "colorama", marker = "sys_platform == 'win32'" },
56
+ { name = "iniconfig" },
57
+ { name = "packaging" },
58
+ { name = "pluggy" },
59
+ { name = "pygments" },
60
+ ]
61
+ sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
62
+ wheels = [
63
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
64
+ ]
65
+
66
+ [[package]]
67
+ name = "sltcore"
68
+ version = "0.1.0"
69
+ source = { editable = "." }
70
+
71
+ [package.dev-dependencies]
72
+ dev = [
73
+ { name = "pytest" },
74
+ ]
75
+
76
+ [package.metadata]
77
+
78
+ [package.metadata.requires-dev]
79
+ dev = [{ name = "pytest", specifier = ">=9.1.1" }]