smithtune 0.0.1__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.
- smithtune-0.0.1/PKG-INFO +62 -0
- smithtune-0.0.1/README.md +52 -0
- smithtune-0.0.1/pyproject.toml +26 -0
- smithtune-0.0.1/setup.cfg +4 -0
- smithtune-0.0.1/src/smithtune/__init__.py +6 -0
- smithtune-0.0.1/src/smithtune/__main__.py +3 -0
- smithtune-0.0.1/src/smithtune/cli.py +32 -0
- smithtune-0.0.1/src/smithtune/splitting.py +101 -0
- smithtune-0.0.1/src/smithtune.egg-info/PKG-INFO +62 -0
- smithtune-0.0.1/src/smithtune.egg-info/SOURCES.txt +12 -0
- smithtune-0.0.1/src/smithtune.egg-info/dependency_links.txt +1 -0
- smithtune-0.0.1/src/smithtune.egg-info/entry_points.txt +2 -0
- smithtune-0.0.1/src/smithtune.egg-info/top_level.txt +1 -0
- smithtune-0.0.1/tests/test_splitting.py +163 -0
smithtune-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smithtune
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Utilities for preparing conversational datasets.
|
|
5
|
+
Classifier: Development Status :: 3 - Alpha
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Topic :: Utilities
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# smithtune
|
|
12
|
+
|
|
13
|
+
Utilities for preparing conversational datasets. Runs locally with no runtime dependencies.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Requires Python 3.10 or newer.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install smithtune
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Split a dataset
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
smithtune split data.jsonl --group-by conversation_id --output-dir splits/
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Each input line must be a JSON object with a top-level group field containing a nonempty string or integer:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{"conversation_id":"a","messages":[{"role":"user","content":"Hello"}]}
|
|
33
|
+
{"conversation_id":"a","messages":[{"role":"assistant","content":"Hi"}]}
|
|
34
|
+
{"conversation_id":"b","messages":[{"role":"user","content":"Good morning"}]}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Creates `train.jsonl`, `validation.jsonl`, and `test.jsonl` and prints record counts. Records with the same group ID always go into the same file. Use IDs that are unique across your input sources.
|
|
38
|
+
|
|
39
|
+
Defaults target 80% training, 10% validation, and 10% test **by group**. Hash-based assignment gives approximate proportions; small datasets may have empty splits. Adding or reordering records does not change a group's assignment when its ID, seed, and fractions stay the same.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
smithtune split data.jsonl --group-by conversation_id --output-dir splits-v2/ \
|
|
43
|
+
--validation-fraction 0.15 --test-fraction 0.15 --seed 42
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The splitter preserves record bytes and order within each output file, adding a final newline if needed. It validates the entire input in memory before writing. Blank lines, invalid JSON, duplicate JSON keys, and missing or invalid group IDs are rejected with a line number. String IDs and integer IDs are distinct.
|
|
47
|
+
|
|
48
|
+
The output directory must not exist, and its parent directory must exist. Existing files are never overwritten. No data is uploaded or fetched.
|
|
49
|
+
|
|
50
|
+
## Python
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from smithtune import split_jsonl
|
|
54
|
+
|
|
55
|
+
counts = split_jsonl("data.jsonl", "splits", group_by="conversation_id")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
PYTHONPATH=src python -m unittest discover -s tests -v
|
|
62
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# smithtune
|
|
2
|
+
|
|
3
|
+
Utilities for preparing conversational datasets. Runs locally with no runtime dependencies.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Requires Python 3.10 or newer.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install smithtune
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Split a dataset
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
smithtune split data.jsonl --group-by conversation_id --output-dir splits/
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Each input line must be a JSON object with a top-level group field containing a nonempty string or integer:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{"conversation_id":"a","messages":[{"role":"user","content":"Hello"}]}
|
|
23
|
+
{"conversation_id":"a","messages":[{"role":"assistant","content":"Hi"}]}
|
|
24
|
+
{"conversation_id":"b","messages":[{"role":"user","content":"Good morning"}]}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Creates `train.jsonl`, `validation.jsonl`, and `test.jsonl` and prints record counts. Records with the same group ID always go into the same file. Use IDs that are unique across your input sources.
|
|
28
|
+
|
|
29
|
+
Defaults target 80% training, 10% validation, and 10% test **by group**. Hash-based assignment gives approximate proportions; small datasets may have empty splits. Adding or reordering records does not change a group's assignment when its ID, seed, and fractions stay the same.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
smithtune split data.jsonl --group-by conversation_id --output-dir splits-v2/ \
|
|
33
|
+
--validation-fraction 0.15 --test-fraction 0.15 --seed 42
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The splitter preserves record bytes and order within each output file, adding a final newline if needed. It validates the entire input in memory before writing. Blank lines, invalid JSON, duplicate JSON keys, and missing or invalid group IDs are rejected with a line number. String IDs and integer IDs are distinct.
|
|
37
|
+
|
|
38
|
+
The output directory must not exist, and its parent directory must exist. Existing files are never overwritten. No data is uploaded or fetched.
|
|
39
|
+
|
|
40
|
+
## Python
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from smithtune import split_jsonl
|
|
44
|
+
|
|
45
|
+
counts = split_jsonl("data.jsonl", "splits", group_by="conversation_id")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
PYTHONPATH=src python -m unittest discover -s tests -v
|
|
52
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77,<85"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "smithtune"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Utilities for preparing conversational datasets."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = []
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Topic :: Utilities",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
smithtune = "smithtune.cli:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
23
|
+
include = ["smithtune*"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
include-package-data = false
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Command-line interface for local dataset utilities."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__, split_jsonl
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv=None):
|
|
11
|
+
parser = argparse.ArgumentParser(description="Utilities for preparing conversational datasets.")
|
|
12
|
+
parser.add_argument("--version", action="version", version=f"smithtune {__version__}")
|
|
13
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
14
|
+
split = commands.add_parser("split", help="split a local JSONL file by group")
|
|
15
|
+
split.add_argument("input", help="input JSONL file")
|
|
16
|
+
split.add_argument("--group-by", required=True, help="top-level string or integer group field")
|
|
17
|
+
split.add_argument("--output-dir", required=True, help="new directory for the split files")
|
|
18
|
+
split.add_argument("--validation-fraction", type=float, default=0.1)
|
|
19
|
+
split.add_argument("--test-fraction", type=float, default=0.1)
|
|
20
|
+
split.add_argument("--seed", type=int, default=42)
|
|
21
|
+
args = parser.parse_args(argv)
|
|
22
|
+
try:
|
|
23
|
+
counts = split_jsonl(
|
|
24
|
+
args.input, args.output_dir, group_by=args.group_by,
|
|
25
|
+
validation_fraction=args.validation_fraction,
|
|
26
|
+
test_fraction=args.test_fraction, seed=args.seed,
|
|
27
|
+
)
|
|
28
|
+
except (ValueError, OSError) as error:
|
|
29
|
+
print(f"smithtune: {error}", file=sys.stderr)
|
|
30
|
+
return 2
|
|
31
|
+
print(json.dumps(counts))
|
|
32
|
+
return 0
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Deterministic, grouped splitting of local JSONL files."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
from contextlib import ExitStack
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
SPLITS = ("train", "validation", "test")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _object(pairs):
|
|
13
|
+
result = {}
|
|
14
|
+
for key, value in pairs:
|
|
15
|
+
if key in result:
|
|
16
|
+
raise ValueError("duplicate JSON key")
|
|
17
|
+
result[key] = value
|
|
18
|
+
return result
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _reject_constant(value):
|
|
22
|
+
raise ValueError("nonstandard JSON constant")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def split_jsonl(
|
|
26
|
+
input_path: str | Path,
|
|
27
|
+
output_dir: str | Path,
|
|
28
|
+
*,
|
|
29
|
+
group_by: str,
|
|
30
|
+
validation_fraction: float = 0.1,
|
|
31
|
+
test_fraction: float = 0.1,
|
|
32
|
+
seed: int = 42,
|
|
33
|
+
) -> dict[str, int]:
|
|
34
|
+
"""Split JSON objects by a top-level string or integer group ID.
|
|
35
|
+
|
|
36
|
+
Return record counts per split. Validate all input before creating output.
|
|
37
|
+
Group assignments remain stable with unchanged IDs, seed and fractions.
|
|
38
|
+
The complete input is held in memory. Existing output paths are rejected.
|
|
39
|
+
"""
|
|
40
|
+
if not isinstance(group_by, str) or not group_by:
|
|
41
|
+
raise ValueError("group_by must be a nonempty field name")
|
|
42
|
+
if type(seed) is not int:
|
|
43
|
+
raise ValueError("seed must be an integer")
|
|
44
|
+
for fraction in (validation_fraction, test_fraction):
|
|
45
|
+
if (isinstance(fraction, bool) or not isinstance(fraction, (int, float))
|
|
46
|
+
or not math.isfinite(fraction) or not 0 <= fraction <= 1):
|
|
47
|
+
raise ValueError("fractions must be finite numbers between 0 and 1")
|
|
48
|
+
if validation_fraction + test_fraction > 1:
|
|
49
|
+
raise ValueError("validation and test fractions cannot total more than 1")
|
|
50
|
+
|
|
51
|
+
destination = Path(output_dir)
|
|
52
|
+
if destination.exists() or destination.is_symlink():
|
|
53
|
+
raise FileExistsError(f"output directory already exists: {destination}")
|
|
54
|
+
|
|
55
|
+
partitions: dict[str, list[bytes]] = {name: [] for name in SPLITS}
|
|
56
|
+
validation_cutoff = int(validation_fraction * 2**256)
|
|
57
|
+
test_cutoff = int((validation_fraction + test_fraction) * 2**256)
|
|
58
|
+
with Path(input_path).open("rb") as source:
|
|
59
|
+
for line_number, raw in enumerate(source, 1):
|
|
60
|
+
try:
|
|
61
|
+
record = json.loads(
|
|
62
|
+
raw.decode("utf-8"), object_pairs_hook=_object,
|
|
63
|
+
parse_constant=_reject_constant,
|
|
64
|
+
)
|
|
65
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
66
|
+
raise ValueError(f"line {line_number}: invalid JSON object") from None
|
|
67
|
+
if not isinstance(record, dict):
|
|
68
|
+
raise ValueError(f"line {line_number}: expected a JSON object")
|
|
69
|
+
group = record.get(group_by)
|
|
70
|
+
if not (type(group) is int or isinstance(group, str) and bool(group.strip())):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"line {line_number}: group field must contain a nonempty string or integer"
|
|
73
|
+
)
|
|
74
|
+
identity = json.dumps([seed, group], ensure_ascii=True, separators=(",", ":"))
|
|
75
|
+
rank = int.from_bytes(hashlib.sha256(identity.encode("ascii")).digest(), "big")
|
|
76
|
+
split = "validation" if rank < validation_cutoff else (
|
|
77
|
+
"test" if rank < test_cutoff else "train"
|
|
78
|
+
)
|
|
79
|
+
partitions[split].append(raw if raw.endswith(b"\n") else raw + b"\n")
|
|
80
|
+
|
|
81
|
+
# mkdir is exclusive even if another process created the path during validation.
|
|
82
|
+
destination.mkdir()
|
|
83
|
+
created = []
|
|
84
|
+
try:
|
|
85
|
+
with ExitStack() as stack:
|
|
86
|
+
handles = {}
|
|
87
|
+
for name in SPLITS:
|
|
88
|
+
path = destination / f"{name}.jsonl"
|
|
89
|
+
handles[name] = stack.enter_context(path.open("xb"))
|
|
90
|
+
created.append(path)
|
|
91
|
+
for name, records in partitions.items():
|
|
92
|
+
handles[name].writelines(records)
|
|
93
|
+
except BaseException:
|
|
94
|
+
for path in created:
|
|
95
|
+
path.unlink(missing_ok=True)
|
|
96
|
+
try:
|
|
97
|
+
destination.rmdir()
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
raise
|
|
101
|
+
return {name: len(records) for name, records in partitions.items()}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smithtune
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Utilities for preparing conversational datasets.
|
|
5
|
+
Classifier: Development Status :: 3 - Alpha
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Topic :: Utilities
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# smithtune
|
|
12
|
+
|
|
13
|
+
Utilities for preparing conversational datasets. Runs locally with no runtime dependencies.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Requires Python 3.10 or newer.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install smithtune
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Split a dataset
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
smithtune split data.jsonl --group-by conversation_id --output-dir splits/
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Each input line must be a JSON object with a top-level group field containing a nonempty string or integer:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{"conversation_id":"a","messages":[{"role":"user","content":"Hello"}]}
|
|
33
|
+
{"conversation_id":"a","messages":[{"role":"assistant","content":"Hi"}]}
|
|
34
|
+
{"conversation_id":"b","messages":[{"role":"user","content":"Good morning"}]}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Creates `train.jsonl`, `validation.jsonl`, and `test.jsonl` and prints record counts. Records with the same group ID always go into the same file. Use IDs that are unique across your input sources.
|
|
38
|
+
|
|
39
|
+
Defaults target 80% training, 10% validation, and 10% test **by group**. Hash-based assignment gives approximate proportions; small datasets may have empty splits. Adding or reordering records does not change a group's assignment when its ID, seed, and fractions stay the same.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
smithtune split data.jsonl --group-by conversation_id --output-dir splits-v2/ \
|
|
43
|
+
--validation-fraction 0.15 --test-fraction 0.15 --seed 42
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The splitter preserves record bytes and order within each output file, adding a final newline if needed. It validates the entire input in memory before writing. Blank lines, invalid JSON, duplicate JSON keys, and missing or invalid group IDs are rejected with a line number. String IDs and integer IDs are distinct.
|
|
47
|
+
|
|
48
|
+
The output directory must not exist, and its parent directory must exist. Existing files are never overwritten. No data is uploaded or fetched.
|
|
49
|
+
|
|
50
|
+
## Python
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from smithtune import split_jsonl
|
|
54
|
+
|
|
55
|
+
counts = split_jsonl("data.jsonl", "splits", group_by="conversation_id")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
PYTHONPATH=src python -m unittest discover -s tests -v
|
|
62
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/smithtune/__init__.py
|
|
4
|
+
src/smithtune/__main__.py
|
|
5
|
+
src/smithtune/cli.py
|
|
6
|
+
src/smithtune/splitting.py
|
|
7
|
+
src/smithtune.egg-info/PKG-INFO
|
|
8
|
+
src/smithtune.egg-info/SOURCES.txt
|
|
9
|
+
src/smithtune.egg-info/dependency_links.txt
|
|
10
|
+
src/smithtune.egg-info/entry_points.txt
|
|
11
|
+
src/smithtune.egg-info/top_level.txt
|
|
12
|
+
tests/test_splitting.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
smithtune
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
from smithtune import split_jsonl
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SplitTests(unittest.TestCase):
|
|
14
|
+
def setUp(self):
|
|
15
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
16
|
+
self.addCleanup(self.temp.cleanup)
|
|
17
|
+
self.root = Path(self.temp.name)
|
|
18
|
+
self.source = self.root / "data.jsonl"
|
|
19
|
+
self.output = self.root / "splits"
|
|
20
|
+
|
|
21
|
+
def write_rows(self, rows):
|
|
22
|
+
self.source.write_text("".join(json.dumps(row) + "\n" for row in rows))
|
|
23
|
+
|
|
24
|
+
def run_split(self, **kwargs):
|
|
25
|
+
return split_jsonl(self.source, self.output, group_by="conversation_id", **kwargs)
|
|
26
|
+
|
|
27
|
+
def assignments(self, directory):
|
|
28
|
+
memberships = {}
|
|
29
|
+
for file in directory.glob("*.jsonl"):
|
|
30
|
+
for line in file.read_text().splitlines():
|
|
31
|
+
group = json.loads(line)["conversation_id"]
|
|
32
|
+
key = (type(group), group)
|
|
33
|
+
self.assertEqual(memberships.setdefault(key, file.stem), file.stem)
|
|
34
|
+
return memberships
|
|
35
|
+
|
|
36
|
+
def test_groups_stay_together_and_all_records_survive(self):
|
|
37
|
+
rows = [{"conversation_id": str(i), "turn": turn} for i in range(300) for turn in range(3)]
|
|
38
|
+
self.write_rows(rows)
|
|
39
|
+
counts = self.run_split()
|
|
40
|
+
self.assertEqual(sum(counts.values()), len(rows))
|
|
41
|
+
self.assertTrue(all(count > 0 for count in counts.values()))
|
|
42
|
+
self.assertEqual(len(self.assignments(self.output)), 300)
|
|
43
|
+
actual = [json.loads(line) for path in self.output.glob("*.jsonl") for line in path.read_text().splitlines()]
|
|
44
|
+
self.assertCountEqual(actual, rows)
|
|
45
|
+
|
|
46
|
+
def test_membership_survives_append_and_reordering(self):
|
|
47
|
+
rows = [{"conversation_id": str(i)} for i in range(100)]
|
|
48
|
+
self.write_rows(rows)
|
|
49
|
+
self.run_split()
|
|
50
|
+
before = self.assignments(self.output)
|
|
51
|
+
self.output = self.root / "second"
|
|
52
|
+
self.write_rows(list(reversed(rows)) + [{"conversation_id": "new"}])
|
|
53
|
+
self.run_split()
|
|
54
|
+
after = self.assignments(self.output)
|
|
55
|
+
self.assertTrue(all(after[key] == value for key, value in before.items()))
|
|
56
|
+
|
|
57
|
+
def test_exact_record_bytes_and_order_preserved(self):
|
|
58
|
+
raw = b'{ "conversation_id": "a", "text": "caf\\u00e9" }\r\n{"conversation_id":"a","n":1.00}'
|
|
59
|
+
self.source.write_bytes(raw)
|
|
60
|
+
self.run_split(validation_fraction=0, test_fraction=0)
|
|
61
|
+
self.assertEqual((self.output / "train.jsonl").read_bytes(), raw + b"\n")
|
|
62
|
+
|
|
63
|
+
def test_invalid_records_create_no_output(self):
|
|
64
|
+
invalid = [b"\n", b"{bad}\n", b"[]\n", b"{}\n", b'{"conversation_id":null}\n',
|
|
65
|
+
b'{"conversation_id":true}\n', b'{"conversation_id":1.5}\n',
|
|
66
|
+
b'{"conversation_id":" "}\n', b'{"conversation_id":[]}\n',
|
|
67
|
+
b'{"conversation_id":"a","x":NaN}\n',
|
|
68
|
+
b'{"conversation_id":"a","conversation_id":"b"}\n',
|
|
69
|
+
b'{"conversation_id":"a","text":"\xff"}\n']
|
|
70
|
+
for raw in invalid:
|
|
71
|
+
with self.subTest(raw=raw):
|
|
72
|
+
self.source.write_bytes(b'{"conversation_id":"valid"}\n' + raw)
|
|
73
|
+
with self.assertRaisesRegex(ValueError, "line 2"):
|
|
74
|
+
self.run_split()
|
|
75
|
+
self.assertFalse(self.output.exists())
|
|
76
|
+
|
|
77
|
+
def test_existing_directory_is_untouched(self):
|
|
78
|
+
self.write_rows([{"conversation_id": "a"}])
|
|
79
|
+
self.output.mkdir()
|
|
80
|
+
marker = self.output / "train.jsonl"
|
|
81
|
+
marker.write_bytes(b"keep me")
|
|
82
|
+
with self.assertRaises(FileExistsError):
|
|
83
|
+
self.run_split()
|
|
84
|
+
self.assertEqual(marker.read_bytes(), b"keep me")
|
|
85
|
+
|
|
86
|
+
def test_output_cannot_be_input_file(self):
|
|
87
|
+
self.write_rows([{"conversation_id": "a"}])
|
|
88
|
+
before = self.source.read_bytes()
|
|
89
|
+
self.output = self.source
|
|
90
|
+
with self.assertRaises(FileExistsError):
|
|
91
|
+
self.run_split()
|
|
92
|
+
self.assertEqual(self.source.read_bytes(), before)
|
|
93
|
+
|
|
94
|
+
def test_dangling_symlink_rejected(self):
|
|
95
|
+
self.write_rows([])
|
|
96
|
+
self.output.symlink_to(self.root / "missing")
|
|
97
|
+
with self.assertRaises(FileExistsError):
|
|
98
|
+
self.run_split()
|
|
99
|
+
self.assertTrue(self.output.is_symlink())
|
|
100
|
+
|
|
101
|
+
def test_invalid_fractions_rejected(self):
|
|
102
|
+
self.write_rows([])
|
|
103
|
+
for fraction in [-0.1, 1.1, float("nan"), float("inf"), True, "0.1"]:
|
|
104
|
+
with self.subTest(fraction=fraction):
|
|
105
|
+
with self.assertRaises(ValueError):
|
|
106
|
+
self.run_split(validation_fraction=fraction)
|
|
107
|
+
with self.assertRaises(ValueError):
|
|
108
|
+
self.run_split(validation_fraction=0.7, test_fraction=0.4)
|
|
109
|
+
self.assertFalse(self.output.exists())
|
|
110
|
+
|
|
111
|
+
def test_zero_and_one_fractions(self):
|
|
112
|
+
self.write_rows([{"conversation_id": i} for i in range(10)])
|
|
113
|
+
counts = self.run_split(validation_fraction=1, test_fraction=0)
|
|
114
|
+
self.assertEqual(counts, {"train": 0, "validation": 10, "test": 0})
|
|
115
|
+
|
|
116
|
+
def test_empty_input(self):
|
|
117
|
+
self.write_rows([])
|
|
118
|
+
self.assertEqual(self.run_split(), {"train": 0, "validation": 0, "test": 0})
|
|
119
|
+
self.assertEqual(len(list(self.output.glob("*.jsonl"))), 3)
|
|
120
|
+
|
|
121
|
+
def test_string_and_integer_ids_are_distinct(self):
|
|
122
|
+
self.write_rows([{"conversation_id": i} for i in range(100)] +
|
|
123
|
+
[{"conversation_id": str(i)} for i in range(100)])
|
|
124
|
+
self.run_split()
|
|
125
|
+
memberships = self.assignments(self.output)
|
|
126
|
+
self.assertTrue(any(memberships[int, i] != memberships[str, str(i)] for i in range(100)))
|
|
127
|
+
|
|
128
|
+
def test_invalid_group_field_and_seed(self):
|
|
129
|
+
self.write_rows([])
|
|
130
|
+
for kwargs in [{"group_by": ""}, {"group_by": None}, {"group_by": "id", "seed": True}]:
|
|
131
|
+
with self.assertRaises(ValueError):
|
|
132
|
+
split_jsonl(self.source, self.output, **kwargs)
|
|
133
|
+
|
|
134
|
+
def test_failed_output_creation_cleans_up_own_files(self):
|
|
135
|
+
self.write_rows([{"conversation_id": "a"}])
|
|
136
|
+
original = Path.open
|
|
137
|
+
|
|
138
|
+
def fail_second(path, *args, **kwargs):
|
|
139
|
+
if path.name == "validation.jsonl":
|
|
140
|
+
raise OSError("simulated full disk")
|
|
141
|
+
return original(path, *args, **kwargs)
|
|
142
|
+
|
|
143
|
+
with patch.object(Path, "open", fail_second):
|
|
144
|
+
with self.assertRaisesRegex(OSError, "simulated full disk"):
|
|
145
|
+
self.run_split()
|
|
146
|
+
self.assertFalse(self.output.exists())
|
|
147
|
+
|
|
148
|
+
def test_cli_reports_counts_and_readable_error(self):
|
|
149
|
+
self.write_rows([{"conversation_id": "a"}])
|
|
150
|
+
env = {**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")}
|
|
151
|
+
command = [sys.executable, "-m", "smithtune", "split", str(self.source),
|
|
152
|
+
"--group-by", "conversation_id", "--output-dir", str(self.output)]
|
|
153
|
+
result = subprocess.run(command, capture_output=True, text=True, env=env)
|
|
154
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
155
|
+
self.assertEqual(sum(json.loads(result.stdout).values()), 1)
|
|
156
|
+
again = subprocess.run(command, capture_output=True, text=True, env=env)
|
|
157
|
+
self.assertEqual(again.returncode, 2)
|
|
158
|
+
self.assertIn("already exists", again.stderr)
|
|
159
|
+
self.assertNotIn("Traceback", again.stderr)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
unittest.main()
|