xaeian 0.7.2__tar.gz → 0.7.3__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.3}/PKG-INFO +1 -1
- {xaeian-0.7.2 → xaeian-0.7.3}/pyproject.toml +1 -1
- xaeian-0.7.3/tests/test_colors.py +17 -0
- xaeian-0.7.3/tests/test_cstruct.py +110 -0
- xaeian-0.7.3/tests/test_files.py +275 -0
- xaeian-0.7.3/tests/test_log.py +84 -0
- xaeian-0.7.3/tests/test_table.py +144 -0
- xaeian-0.7.3/tests/test_xstring.py +102 -0
- xaeian-0.7.3/tests/test_xtime.py +158 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/__init__.py +1 -1
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/net/sftp.py +21 -9
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian.egg-info/PKG-INFO +1 -1
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian.egg-info/SOURCES.txt +7 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/readme.md +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/setup.cfg +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/__main__.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/cli/__init__.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/cli/_args.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/cli/dupes.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/cli/fonts.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/cli/tree.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/cli/wifi.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/cmd.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/colors.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/crc.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/cstruct.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/__init__.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/abstract.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/abstract_async.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/errors.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/kv.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/kv_async.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/kv_common.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/mysql.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/mysql_async.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/postgres.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/postgres_async.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/sqlite.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/sqlite_async.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/db/utils.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/dsp.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/eda/__init__.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/eda/ee.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/eda/fp.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/eda/kicad.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/eda/kicad_clean.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/eda/spice.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/eda/sym.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/files/__init__.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/files/bound.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/files/config.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/files/csv.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/files/dir.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/files/file.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/files/ini.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/files/json.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/files/path.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/files/yaml.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/files_async.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/log.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/media/__init__.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/media/ico.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/media/img.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/media/meta.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/media/min.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/media/pdf.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/media/utils.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/net/__init__.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/net/ftp.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/plot.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/serial/__init__.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/serial/port.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/serial/rec-app.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/serial/rec.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/serial/sh.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/table.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/xstring.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian/xtime.py +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian.egg-info/dependency_links.txt +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian.egg-info/entry_points.txt +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian.egg-info/requires.txt +0 -0
- {xaeian-0.7.2 → xaeian-0.7.3}/xaeian.egg-info/top_level.txt +0 -0
|
@@ -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.3"
|
|
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"}
|
|
@@ -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,110 @@
|
|
|
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 bitfield():
|
|
42
|
+
s = Struct(name="flags").add(Bitfield("st", [("on", 1), ("err", 1), ("mode", 6)]))
|
|
43
|
+
data = {"st": {"on": 1, "err": 0, "mode": 42}}
|
|
44
|
+
assert s.decode(s.encode(data)) == data
|
|
45
|
+
|
|
46
|
+
def optional_field_uses_default():
|
|
47
|
+
s = Struct(name="opt").add(
|
|
48
|
+
Field(Type.uint8, "a"), Field(Type.uint8, "b", optional=True, default=9),
|
|
49
|
+
)
|
|
50
|
+
assert s.decode(s.encode({"a": 1})) == {"a": 1, "b": 9}
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize("endian, order", [(Endian.little, "little"), (Endian.big, "big")])
|
|
53
|
+
def endian_mirrors_bytes(endian, order):
|
|
54
|
+
s = Struct(name=f"e_{order}", endian=endian).add(Field(Type.uint16, "n"))
|
|
55
|
+
assert s.encode({"n": 258}) == (258).to_bytes(2, order) # 258 == 0x0102
|
|
56
|
+
assert s.decode(s.encode({"n": 258})) == {"n": 258}
|
|
57
|
+
|
|
58
|
+
@pytest.mark.parametrize("kind, body", [(0, {"a": 99}), (1, {"b": 1.5, "c": 2.5})])
|
|
59
|
+
def variant_selects_layout(kind, body):
|
|
60
|
+
s = Struct(name="msg").add(
|
|
61
|
+
Field(Type.uint8, "kind"),
|
|
62
|
+
Variant("body", "kind", {
|
|
63
|
+
0: [Field(Type.uint32, "a")],
|
|
64
|
+
1: [Field(Type.float, "b"), Field(Type.float, "c")],
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
msg = {"kind": kind, "body": body}
|
|
68
|
+
assert s.decode(s.encode(msg)) == msg
|
|
69
|
+
|
|
70
|
+
@pytest.mark.usefixtures("registry")
|
|
71
|
+
def frame_routes_by_code():
|
|
72
|
+
pos = Struct(code=1, name="pos").add(Field(Type.int16, "x"), Field(Type.int16, "y"))
|
|
73
|
+
temp = Struct(code=2, name="temp").add(Field(Type.float, "t"))
|
|
74
|
+
frame = Frame(pos, temp)
|
|
75
|
+
data = {"pos": {"x": -3, "y": 7}, "temp": {"t": 21.5}}
|
|
76
|
+
assert frame.decode(frame.encode(data)) == data
|
|
77
|
+
|
|
78
|
+
def alignment_padding_roundtrips_each_record():
|
|
79
|
+
# regression: decode must consume the padding encode appends after each record
|
|
80
|
+
s = Struct(name="aligned", align=4).add(Field(Type.uint8, "a"), Field(Type.uint16, "b"))
|
|
81
|
+
recs = [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
|
82
|
+
assert s.decode(s.encode(recs)) == recs
|
|
83
|
+
|
|
84
|
+
def crc_detects_corruption():
|
|
85
|
+
s = Struct(name="guarded", crc=crc32_iso).add(Field(Type.uint16, "n"))
|
|
86
|
+
frame = s.encode({"n": 0xBEEF})
|
|
87
|
+
assert s.decode(frame) == {"n": 0xBEEF}
|
|
88
|
+
corrupt = bytes([frame[0] ^ 0xFF]) + frame[1:]
|
|
89
|
+
with pytest.raises(ValueError):
|
|
90
|
+
s.decode(corrupt)
|
|
91
|
+
|
|
92
|
+
def missing_required_field_raises():
|
|
93
|
+
s = Struct(name="req").add(Field(Type.uint8, "a"))
|
|
94
|
+
with pytest.raises(KeyError):
|
|
95
|
+
s.encode({})
|
|
96
|
+
|
|
97
|
+
@pytest.fixture
|
|
98
|
+
def registry():
|
|
99
|
+
saved = dict(Struct._codes)
|
|
100
|
+
Struct._codes.clear()
|
|
101
|
+
yield
|
|
102
|
+
Struct._codes.clear()
|
|
103
|
+
Struct._codes.update(saved)
|
|
104
|
+
|
|
105
|
+
@pytest.mark.usefixtures("registry")
|
|
106
|
+
def code_registry_is_idempotent():
|
|
107
|
+
Struct(code=0xA1, name="Frame")
|
|
108
|
+
Struct(code=0xA1, name="Frame") # same code + name: allowed
|
|
109
|
+
with pytest.raises(ValueError):
|
|
110
|
+
Struct(code=0xA1, name="Other") # same code, new name: rejected
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# tests/test_files.py
|
|
2
|
+
|
|
3
|
+
"""File submodule: PATH/FILE/DIR + INI/CSV/JSON/YAML round-trips, in both modes
|
|
4
|
+
(global `file_context` and object-bound `Files`)."""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
import pytest
|
|
9
|
+
from xaeian import file_context, Files, PATH, DIR, FILE, INI, CSV, JSON, YAML
|
|
10
|
+
|
|
11
|
+
@pytest.fixture(autouse=True)
|
|
12
|
+
def _root_context(tmp_path):
|
|
13
|
+
# every test runs under a global context rooted at a fresh temp dir (restored on exit);
|
|
14
|
+
# tests that need the path itself take the standard `tmp_path` fixture directly
|
|
15
|
+
with file_context(root_path=str(tmp_path)):
|
|
16
|
+
yield
|
|
17
|
+
|
|
18
|
+
#------------------------------------------------------------------------------------------ PATH
|
|
19
|
+
|
|
20
|
+
@pytest.mark.parametrize("method, expected", [
|
|
21
|
+
("basename", "c.txt"),
|
|
22
|
+
("dirname", "a/b"),
|
|
23
|
+
("stem", "c"),
|
|
24
|
+
("ext", ".txt"),
|
|
25
|
+
])
|
|
26
|
+
def path_extracts_components(method, expected):
|
|
27
|
+
assert getattr(PATH, method)("a/b/c.txt") == expected
|
|
28
|
+
|
|
29
|
+
def path_with_and_ensure_suffix():
|
|
30
|
+
assert PATH.with_suffix("a/b.txt", ".md") == "a/b.md"
|
|
31
|
+
assert PATH.ensure_suffix("a/b", ".txt") == "a/b.txt"
|
|
32
|
+
assert PATH.ensure_suffix("a/b.txt", ".txt") == "a/b.txt" # idempotent
|
|
33
|
+
|
|
34
|
+
def path_normalize_posix_and_collapse():
|
|
35
|
+
assert PATH.normalize("a\\b//c/./d") == "a/b/c/d"
|
|
36
|
+
|
|
37
|
+
def path_match_glob():
|
|
38
|
+
assert PATH.match("src/main.py", "*.py")
|
|
39
|
+
assert not PATH.match("src/main.py", "*.txt")
|
|
40
|
+
|
|
41
|
+
def path_expand_env_and_user():
|
|
42
|
+
os.environ["XAEIAN_T"] = "/env/val"
|
|
43
|
+
assert PATH.expand("$XAEIAN_T/y") == "/env/val/y"
|
|
44
|
+
assert "~" not in PATH.expand("~/x") # ~ expands away
|
|
45
|
+
|
|
46
|
+
def path_resolve_joins_root_for_relative(tmp_path):
|
|
47
|
+
resolved = PATH.resolve("data/x.txt")
|
|
48
|
+
assert resolved.startswith(PATH.normalize(str(tmp_path))) and resolved.endswith("/data/x.txt")
|
|
49
|
+
|
|
50
|
+
def path_resolve_keeps_absolute(tmp_path):
|
|
51
|
+
abs_in = str(tmp_path / "x.txt")
|
|
52
|
+
assert PATH.resolve(abs_in) == PATH.normalize(abs_in)
|
|
53
|
+
|
|
54
|
+
def path_join_resolves():
|
|
55
|
+
assert PATH.join("a", "b", "c.txt").endswith("/a/b/c.txt")
|
|
56
|
+
|
|
57
|
+
def path_rel_and_is_under(tmp_path):
|
|
58
|
+
assert PATH.rel(str(tmp_path / "sub" / "f.txt")) == "sub/f.txt"
|
|
59
|
+
assert PATH.rel(str(tmp_path / "p/q/f.txt"), base=str(tmp_path / "p")) == "q/f.txt"
|
|
60
|
+
assert PATH.is_under("sub/f.txt")
|
|
61
|
+
assert not PATH.is_under("../outside")
|
|
62
|
+
|
|
63
|
+
def path_ext_empty_for_no_extension():
|
|
64
|
+
assert PATH.ext("noext") == ""
|
|
65
|
+
|
|
66
|
+
def path_exists_is_file_is_dir():
|
|
67
|
+
FILE.save("a.txt", "x")
|
|
68
|
+
DIR.ensure("d/")
|
|
69
|
+
assert PATH.exists("a.txt") and PATH.is_file("a.txt") and not PATH.is_dir("a.txt")
|
|
70
|
+
assert PATH.is_dir("d") and not PATH.is_file("d")
|
|
71
|
+
assert not PATH.exists("nope.txt")
|
|
72
|
+
|
|
73
|
+
#------------------------------------------------------------------------------------------ FILE
|
|
74
|
+
|
|
75
|
+
def file_text_roundtrip():
|
|
76
|
+
FILE.save("a.txt", "Hello!")
|
|
77
|
+
assert FILE.load("a.txt") == "Hello!"
|
|
78
|
+
|
|
79
|
+
def file_binary_roundtrip():
|
|
80
|
+
FILE.save("b.bin", b"\x00\x01\x02")
|
|
81
|
+
assert FILE.load("b.bin", binary=True) == b"\x00\x01\x02"
|
|
82
|
+
|
|
83
|
+
def file_append_and_lines():
|
|
84
|
+
FILE.save("c.txt", "a")
|
|
85
|
+
FILE.append("c.txt", "b")
|
|
86
|
+
FILE.append_line("c.txt", "c")
|
|
87
|
+
assert FILE.load("c.txt") == "abc\n"
|
|
88
|
+
FILE.save_lines("d.txt", ["x\n", "y\n"])
|
|
89
|
+
assert FILE.load_lines("d.txt") == ["x\n", "y\n"]
|
|
90
|
+
assert list(FILE.iter_lines("d.txt", strip=True)) == ["x", "y"]
|
|
91
|
+
|
|
92
|
+
def file_save_creates_parent_dirs():
|
|
93
|
+
FILE.save("deep/nested/x.txt", "ok") # parent dirs auto-created
|
|
94
|
+
assert FILE.load("deep/nested/x.txt") == "ok"
|
|
95
|
+
|
|
96
|
+
def file_exists_and_remove():
|
|
97
|
+
FILE.save("e.txt", "x")
|
|
98
|
+
assert FILE.exists("e.txt") and not FILE.exists(["e.txt", "missing"])
|
|
99
|
+
assert FILE.remove("e.txt") and not FILE.exists("e.txt")
|
|
100
|
+
assert FILE.remove("missing", missing_ok=True) is False
|
|
101
|
+
|
|
102
|
+
def file_load_missing_raises():
|
|
103
|
+
with pytest.raises(FileNotFoundError):
|
|
104
|
+
FILE.load("nope.txt")
|
|
105
|
+
|
|
106
|
+
def file_hash_and_size():
|
|
107
|
+
FILE.save("h.txt", "abc")
|
|
108
|
+
assert FILE.hash("h.txt", algo="md5") == "900150983cd24fb0d6963f7d28e17f72" # md5("abc")
|
|
109
|
+
assert FILE.hash("h.txt") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" # sha256
|
|
110
|
+
assert FILE.size("h.txt") == 3 and FILE.mtime("h.txt") > 0
|
|
111
|
+
|
|
112
|
+
#------------------------------------------------------------------------------------------- DIR
|
|
113
|
+
|
|
114
|
+
def dir_ensure_creates_dirs(tmp_path):
|
|
115
|
+
DIR.ensure("a/b/c/")
|
|
116
|
+
assert (tmp_path / "a/b/c").is_dir()
|
|
117
|
+
DIR.ensure("x/y/file.txt", is_file=True) # only the parent dir is created
|
|
118
|
+
assert (tmp_path / "x/y").is_dir() and not (tmp_path / "x/y/file.txt").exists()
|
|
119
|
+
|
|
120
|
+
def dir_file_list_filters_by_ext():
|
|
121
|
+
FILE.save("p/a.txt", "1"); FILE.save("p/b.py", "2"); FILE.save("p/sub/c.txt", "3")
|
|
122
|
+
assert sorted(DIR.file_list("p", exts=[".txt"], local=True)) == ["a.txt", "sub/c.txt"]
|
|
123
|
+
|
|
124
|
+
def dir_file_list_match_and_blacklist():
|
|
125
|
+
FILE.save("p/a.txt", "1"); FILE.save("p/sub/c.py", "2")
|
|
126
|
+
assert DIR.file_list("p", match="*.py", local=True) == ["sub/c.py"]
|
|
127
|
+
assert DIR.file_list("p", blacklist=["sub"], local=True) == ["a.txt"]
|
|
128
|
+
|
|
129
|
+
def dir_folder_list_and_iter_files():
|
|
130
|
+
FILE.save("base/a.txt", "1"); FILE.save("base/sub/b.txt", "2")
|
|
131
|
+
assert DIR.folder_list("base", basename=True) == ["sub"]
|
|
132
|
+
assert sorted(PATH.rel(f) for f in DIR.iter_files("base")) == ["base/a.txt", "base/sub/b.txt"]
|
|
133
|
+
|
|
134
|
+
def dir_copy_move_remove(tmp_path):
|
|
135
|
+
FILE.save("src/a.txt", "x")
|
|
136
|
+
DIR.copy("src", "dst")
|
|
137
|
+
assert (tmp_path / "dst/a.txt").read_text(encoding="utf-8") == "x"
|
|
138
|
+
DIR.move("dst", "moved")
|
|
139
|
+
assert (tmp_path / "moved/a.txt").exists() and not (tmp_path / "dst").exists()
|
|
140
|
+
DIR.remove("moved")
|
|
141
|
+
assert not (tmp_path / "moved").exists()
|
|
142
|
+
|
|
143
|
+
def dir_copy_single_file():
|
|
144
|
+
FILE.save("f.txt", "X")
|
|
145
|
+
DIR.copy("f.txt", "g.txt")
|
|
146
|
+
assert FILE.load("g.txt") == "X"
|
|
147
|
+
|
|
148
|
+
def dir_remove_nonexistent_raises():
|
|
149
|
+
with pytest.raises(NotADirectoryError):
|
|
150
|
+
DIR.remove("missingdir")
|
|
151
|
+
|
|
152
|
+
def dir_zip_unzip_roundtrip():
|
|
153
|
+
FILE.save("z/a.txt", "1"); FILE.save("z/inner/b.txt", "2")
|
|
154
|
+
DIR.zip("z")
|
|
155
|
+
DIR.unzip("z.zip", "out")
|
|
156
|
+
assert sorted(DIR.file_list("out", local=True)) == ["a.txt", "inner/b.txt"]
|
|
157
|
+
|
|
158
|
+
#--------------------------------------------------------------------------------- INI/CSV/JSON
|
|
159
|
+
|
|
160
|
+
def ini_roundtrip_preserves_types_and_sections():
|
|
161
|
+
data = {"top": 1, "main": {"k": "v", "n": 42, "flag": True, "pi": 1.5}}
|
|
162
|
+
INI.save("c", data)
|
|
163
|
+
assert INI.load("c") == data
|
|
164
|
+
|
|
165
|
+
def ini_load_skips_comments_and_inline():
|
|
166
|
+
FILE.save("z.ini", "; comment\nk = 1 # inline\n[s]\nv = 2\n")
|
|
167
|
+
assert INI.load("z") == {"k": 1, "s": {"v": 2}}
|
|
168
|
+
|
|
169
|
+
def ini_save_writes_inline_comment():
|
|
170
|
+
INI.save("w", {"k": (5, "note")}) # (value, comment) tuple → trailing comment
|
|
171
|
+
assert FILE.load("w.ini").strip() == "k = 5 # note"
|
|
172
|
+
|
|
173
|
+
def ini_parse_hex_and_load_missing_empty():
|
|
174
|
+
assert INI.parse("0x10") == 16
|
|
175
|
+
assert INI.load("none") == {}
|
|
176
|
+
|
|
177
|
+
@pytest.mark.parametrize("value, text", [(None, ""), (True, "true"), (False, "false"), (42, "42")])
|
|
178
|
+
def ini_format_matches(value, text):
|
|
179
|
+
assert INI.format(value) == text
|
|
180
|
+
|
|
181
|
+
@pytest.mark.parametrize("text, value", [
|
|
182
|
+
("true", True), ("false", False), ("42", 42), ("1.5", 1.5), ('"hi"', "hi"),
|
|
183
|
+
])
|
|
184
|
+
def ini_parse_matches(text, value):
|
|
185
|
+
assert INI.parse(text) == value
|
|
186
|
+
|
|
187
|
+
def csv_roundtrip_with_types():
|
|
188
|
+
CSV.save("d", [{"a": 1, "b": 2}, {"a": 3, "b": 4}])
|
|
189
|
+
assert CSV.load("d", types={"a": int, "b": int}) == [{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
|
190
|
+
|
|
191
|
+
def csv_load_raw_and_vectors():
|
|
192
|
+
CSV.save("d", [{"a": 1, "b": 2}, {"a": 3, "b": 4}])
|
|
193
|
+
assert CSV.load_raw("d") == [["a", "b"], ["1", "2"], ["3", "4"]] # raw is strings
|
|
194
|
+
assert CSV.load_vectors("d", types={"a": int, "b": int}) == {"a": [1, 3], "b": [2, 4]}
|
|
195
|
+
|
|
196
|
+
def csv_load_vectors_group_by():
|
|
197
|
+
CSV.save("g", [{"grp": "x", "v": 1}, {"grp": "x", "v": 2}, {"grp": "y", "v": 3}])
|
|
198
|
+
assert CSV.load_vectors("g", types={"v": int}, group_by="grp") == {"x": {"v": [1, 2]}, "y": {"v": [3]}}
|
|
199
|
+
|
|
200
|
+
def csv_add_row_dict_and_list_rows():
|
|
201
|
+
CSV.add_row("e", {"x": 1, "y": 2}); CSV.add_row("e", {"x": 3, "y": 4})
|
|
202
|
+
assert CSV.load("e", types={"x": int, "y": int}) == [{"x": 1, "y": 2}, {"x": 3, "y": 4}]
|
|
203
|
+
CSV.add_row("lst", [1, 2], header=["x", "y"]); CSV.add_row("lst", [3, 4])
|
|
204
|
+
assert CSV.load("lst", types={"x": int, "y": int}) == [{"x": 1, "y": 2}, {"x": 3, "y": 4}]
|
|
205
|
+
|
|
206
|
+
def csv_save_vectors_zips_and_rejects_mismatch():
|
|
207
|
+
CSV.save_vectors("v", [1, 2], [3, 4], header=["a", "b"])
|
|
208
|
+
assert CSV.load("v", types={"a": int, "b": int}) == [{"a": 1, "b": 3}, {"a": 2, "b": 4}]
|
|
209
|
+
with pytest.raises(ValueError):
|
|
210
|
+
CSV.save_vectors("bad", [1, 2], [3]) # unequal column lengths
|
|
211
|
+
|
|
212
|
+
def csv_load_missing_returns_empty():
|
|
213
|
+
assert CSV.load("none") == []
|
|
214
|
+
|
|
215
|
+
def json_roundtrip():
|
|
216
|
+
data = {"a": 1, "b": [1, 2, 3], "c": {"x": True}}
|
|
217
|
+
JSON.save("j", data)
|
|
218
|
+
assert JSON.load("j") == data
|
|
219
|
+
|
|
220
|
+
def json_pretty_and_smart_roundtrip():
|
|
221
|
+
data = {"name": "ok", "xs": [1, 2, 3]}
|
|
222
|
+
JSON.save_pretty("p", data); assert JSON.load("p") == data
|
|
223
|
+
JSON.save_smart("s", data); assert JSON.load("s") == data
|
|
224
|
+
|
|
225
|
+
def json_smart_stays_valid_and_ascii_escapes():
|
|
226
|
+
data = {"xs": [1, 2, 3], "m": {"a": 1}}
|
|
227
|
+
assert json.loads(JSON.smart(data)) == data # smart layout is still valid JSON
|
|
228
|
+
JSON.save("u", {"k": "ą"}, ensure_ascii=True)
|
|
229
|
+
assert "ą" not in FILE.load("u.json") # non-ascii escaped
|
|
230
|
+
|
|
231
|
+
def json_load_missing_returns_default():
|
|
232
|
+
assert JSON.load("nope", otherwise={"def": 1}) == {"def": 1}
|
|
233
|
+
|
|
234
|
+
def yaml_roundtrip():
|
|
235
|
+
data = {"debug": True, "port": 8080, "tags": ["a", "b"]}
|
|
236
|
+
YAML.save("y", data)
|
|
237
|
+
assert YAML.load("y") == data
|
|
238
|
+
|
|
239
|
+
def yaml_pretty_multi_doc_and_missing():
|
|
240
|
+
YAML.save_pretty("yp", {"a": 1, "b": [1, 2]}); assert YAML.load("yp") == {"a": 1, "b": [1, 2]}
|
|
241
|
+
YAML.save_all("m", [{"id": 1}, {"id": 2}]); assert YAML.load_all("m") == [{"id": 1}, {"id": 2}]
|
|
242
|
+
assert YAML.load("none", otherwise=[]) == []
|
|
243
|
+
|
|
244
|
+
#------------------------------------------------------------------------------------ Modes
|
|
245
|
+
|
|
246
|
+
def file_context_restores_previous_root(tmp_path):
|
|
247
|
+
a, b = tmp_path / "A", tmp_path / "B"
|
|
248
|
+
with file_context(root_path=str(a)):
|
|
249
|
+
FILE.save("x.txt", "a")
|
|
250
|
+
with file_context(root_path=str(b)):
|
|
251
|
+
FILE.save("y.txt", "b")
|
|
252
|
+
FILE.save("z.txt", "a2") # back to A after the inner block exits
|
|
253
|
+
assert (a / "x.txt").exists() and (a / "z.txt").exists()
|
|
254
|
+
assert (b / "y.txt").exists() and not (b / "z.txt").exists()
|
|
255
|
+
|
|
256
|
+
def files_object_uses_its_own_root(tmp_path):
|
|
257
|
+
fs = Files(root_path=str(tmp_path / "obj"))
|
|
258
|
+
fs.FILE.save("o.txt", "obj")
|
|
259
|
+
assert (tmp_path / "obj" / "o.txt").read_text(encoding="utf-8") == "obj"
|
|
260
|
+
assert fs.FILE.load("o.txt") == "obj"
|
|
261
|
+
|
|
262
|
+
def files_object_data_namespaces(tmp_path):
|
|
263
|
+
fs = Files(root_path=str(tmp_path / "data"))
|
|
264
|
+
fs.JSON.save("cfg", {"a": 1}); assert fs.JSON.load("cfg") == {"a": 1}
|
|
265
|
+
fs.CSV.save("d", [{"x": 1}]); assert fs.CSV.load("d", types={"x": int}) == [{"x": 1}]
|
|
266
|
+
fs.INI.save("s", {"m": {"k": "v"}}); assert fs.INI.load("s") == {"m": {"k": "v"}}
|
|
267
|
+
|
|
268
|
+
def files_object_independent_of_global_context(tmp_path):
|
|
269
|
+
a, b = tmp_path / "A", tmp_path / "B"
|
|
270
|
+
fs = Files(root_path=str(a))
|
|
271
|
+
with file_context(root_path=str(b)):
|
|
272
|
+
fs.FILE.save("iso.txt", "in-A") # bound to A despite active context B
|
|
273
|
+
FILE.save("ctx.txt", "in-B") # global namespace follows the context → B
|
|
274
|
+
assert (a / "iso.txt").exists() and not (b / "iso.txt").exists()
|
|
275
|
+
assert (b / "ctx.txt").exists()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# tests/test_log.py
|
|
2
|
+
|
|
3
|
+
"""Colored logging: Print (level-filtered output) and Logger (rotating file)."""
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import logging
|
|
7
|
+
from xaeian import logger, Print
|
|
8
|
+
|
|
9
|
+
# Print writes to its `file=`; assertions use substrings (the level marker text
|
|
10
|
+
# is embedded in the colored Ico, so it survives regardless of ANSI codes).
|
|
11
|
+
|
|
12
|
+
def print_filters_below_threshold():
|
|
13
|
+
buf = io.StringIO()
|
|
14
|
+
p = Print(file=buf, level="WRN")
|
|
15
|
+
p.inf("ignored")
|
|
16
|
+
p.err("shown")
|
|
17
|
+
out = buf.getvalue()
|
|
18
|
+
assert "ignored" not in out
|
|
19
|
+
assert "shown" in out and "ERR" in out
|
|
20
|
+
|
|
21
|
+
def print_default_level_shows_debug():
|
|
22
|
+
buf = io.StringIO()
|
|
23
|
+
Print(file=buf).dbg("hello")
|
|
24
|
+
assert "hello" in buf.getvalue()
|
|
25
|
+
|
|
26
|
+
def print_sub_entry_inherits_last_level():
|
|
27
|
+
buf = io.StringIO()
|
|
28
|
+
p = Print(file=buf, level="WRN")
|
|
29
|
+
p.inf("hi"); p.dot("under-inf") # INFO < WRN → both suppressed
|
|
30
|
+
p.err("boom"); p.dot("under-err") # ERROR shown; dot inherits ERROR → shown
|
|
31
|
+
out = buf.getvalue()
|
|
32
|
+
assert "under-inf" not in out
|
|
33
|
+
assert "boom" in out and "under-err" in out
|
|
34
|
+
|
|
35
|
+
def print_level_property_accepts_name_or_int():
|
|
36
|
+
p = Print()
|
|
37
|
+
p.level = "WRN"
|
|
38
|
+
assert p.level == logging.WARNING
|
|
39
|
+
p.level = 10
|
|
40
|
+
assert p.level == 10
|
|
41
|
+
|
|
42
|
+
def print_long_aliases_delegate_to_short():
|
|
43
|
+
buf = io.StringIO()
|
|
44
|
+
p = Print(file=buf, level="WRN")
|
|
45
|
+
p.info("hidden"); p.error("visible")
|
|
46
|
+
out = buf.getvalue()
|
|
47
|
+
assert "hidden" not in out and "visible" in out
|
|
48
|
+
|
|
49
|
+
def print_ok_appends_badge():
|
|
50
|
+
buf = io.StringIO()
|
|
51
|
+
Print(file=buf).ok("done") # INFO ≥ default DBG → shown
|
|
52
|
+
out = buf.getvalue()
|
|
53
|
+
assert "done" in out and "OK" in out
|
|
54
|
+
|
|
55
|
+
# Logger's file handler writes plain, ANSI-stripped lines with 3-char levels.
|
|
56
|
+
|
|
57
|
+
def logger_writes_abbreviated_levels_to_file(tmp_path):
|
|
58
|
+
log = logger("xaeian_test_a", file=str(tmp_path / "a.log"), stream=False, file_lvl=logging.DEBUG)
|
|
59
|
+
log.debug("dbgmsg"); log.error("boom"); log.warning("warn"); log.panic("kaboom")
|
|
60
|
+
content = (tmp_path / "a.log").read_text(encoding="utf-8")
|
|
61
|
+
for line in ("DBG dbgmsg", "ERR boom", "WRN warn", "PNC kaboom"):
|
|
62
|
+
assert line in content
|
|
63
|
+
|
|
64
|
+
def logger_item_inherits_last_level(tmp_path):
|
|
65
|
+
log = logger("xaeian_test_b", file=str(tmp_path / "b.log"), stream=False, file_lvl=logging.DEBUG)
|
|
66
|
+
log.error("failed")
|
|
67
|
+
log.item("detail") # " - " prefix, logged at the last level (ERROR)
|
|
68
|
+
assert "ERR - detail" in (tmp_path / "b.log").read_text(encoding="utf-8")
|
|
69
|
+
|
|
70
|
+
def logger_ok_appends_stripped_badge(tmp_path):
|
|
71
|
+
log = logger("xaeian_test_c", file=str(tmp_path / "c.log"), stream=False, file_lvl=logging.DEBUG)
|
|
72
|
+
log.ok("done")
|
|
73
|
+
assert "INF done OK" in (tmp_path / "c.log").read_text(encoding="utf-8")
|
|
74
|
+
|
|
75
|
+
def logger_respects_file_level(tmp_path):
|
|
76
|
+
log = logger("xaeian_test_d", file=str(tmp_path / "d.log"), stream=False, file_lvl=logging.WARNING)
|
|
77
|
+
log.info("below"); log.warning("at")
|
|
78
|
+
content = (tmp_path / "d.log").read_text(encoding="utf-8")
|
|
79
|
+
assert "below" not in content and "WRN at" in content
|
|
80
|
+
|
|
81
|
+
def logger_file_property_reports_path(tmp_path):
|
|
82
|
+
path = str(tmp_path / "e.log")
|
|
83
|
+
log = logger("xaeian_test_e", file=path, stream=False)
|
|
84
|
+
assert log.file == path
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# tests/test_table.py
|
|
2
|
+
|
|
3
|
+
"""Tabular ops on list[dict]: filter, columns, sort/dedup, group/aggregate, join, markdown."""
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from xaeian.table import (
|
|
7
|
+
where, first, take,
|
|
8
|
+
columns, select, exclude, rename, add_column, pluck, set_defaults,
|
|
9
|
+
sort_by, unique,
|
|
10
|
+
group_by, count_by, aggregate,
|
|
11
|
+
join, concat,
|
|
12
|
+
replace_values, map_column,
|
|
13
|
+
describe, markdown, markdown_raw,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def rows():
|
|
18
|
+
return [
|
|
19
|
+
{"name": "Ann", "dept": "eng", "salary": 100},
|
|
20
|
+
{"name": "Bob", "dept": "eng", "salary": 120},
|
|
21
|
+
{"name": "Cy", "dept": "ops", "salary": 90},
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def where_filters_by_predicate(rows):
|
|
25
|
+
assert where(rows, lambda r: r["salary"] >= 100) == rows[:2]
|
|
26
|
+
|
|
27
|
+
def first_returns_match_or_none(rows):
|
|
28
|
+
assert first(rows, lambda r: r["dept"] == "ops") == rows[2]
|
|
29
|
+
assert first(rows, lambda r: r["dept"] == "hr") is None
|
|
30
|
+
|
|
31
|
+
def take_slices_with_offset(rows):
|
|
32
|
+
assert take(rows, 1, offset=1) == [rows[1]]
|
|
33
|
+
|
|
34
|
+
def columns_from_first_row(rows):
|
|
35
|
+
assert columns(rows) == ["name", "dept", "salary"]
|
|
36
|
+
assert columns([]) == []
|
|
37
|
+
|
|
38
|
+
def select_keeps_only_named_columns(rows):
|
|
39
|
+
assert select(rows, "name") == [{"name": "Ann"}, {"name": "Bob"}, {"name": "Cy"}]
|
|
40
|
+
|
|
41
|
+
def exclude_drops_named_columns(rows):
|
|
42
|
+
assert exclude(rows, "salary", "dept") == [{"name": "Ann"}, {"name": "Bob"}, {"name": "Cy"}]
|
|
43
|
+
|
|
44
|
+
def rename_remaps_keys(rows):
|
|
45
|
+
assert rename(rows, {"dept": "team"})[0] == {"name": "Ann", "team": "eng", "salary": 100}
|
|
46
|
+
|
|
47
|
+
def add_column_computes_in_place(rows):
|
|
48
|
+
out = add_column(rows, "bonus", lambda r: r["salary"] // 10)
|
|
49
|
+
assert out is rows and rows[0]["bonus"] == 10 # mutates and returns the same list
|
|
50
|
+
|
|
51
|
+
def pluck_extracts_one_column(rows):
|
|
52
|
+
assert pluck(rows, "name") == ["Ann", "Bob", "Cy"]
|
|
53
|
+
|
|
54
|
+
def set_defaults_fills_only_missing():
|
|
55
|
+
data = [{"a": 1}, {"a": 2, "b": 9}]
|
|
56
|
+
set_defaults(data, b=0)
|
|
57
|
+
assert data == [{"a": 1, "b": 0}, {"a": 2, "b": 9}] # existing b kept
|
|
58
|
+
|
|
59
|
+
def sort_by_orders_with_none_last():
|
|
60
|
+
data = [{"s": 3}, {"s": None}, {"s": 1}]
|
|
61
|
+
assert pluck(sort_by(data, "s"), "s") == [1, 3, None]
|
|
62
|
+
assert pluck(sort_by(data, "s", reverse=True), "s") == [3, 1, None]
|
|
63
|
+
|
|
64
|
+
def sort_by_tolerates_mixed_types():
|
|
65
|
+
assert pluck(sort_by([{"x": 1}, {"x": "a"}, {"x": 2}], "x"), "x") == [1, 2, "a"]
|
|
66
|
+
|
|
67
|
+
def unique_dedupes_by_key_and_unhashable():
|
|
68
|
+
assert unique([{"id": 1}, {"id": 1}, {"id": 2}], "id") == [{"id": 1}, {"id": 2}]
|
|
69
|
+
assert unique([{"t": [1, 2]}, {"t": [1, 2]}, {"t": [3]}], "t") == [{"t": [1, 2]}, {"t": [3]}]
|
|
70
|
+
|
|
71
|
+
def group_by_buckets_rows(rows):
|
|
72
|
+
groups = group_by(rows, "dept")
|
|
73
|
+
assert list(groups) == ["eng", "ops"] and len(groups["eng"]) == 2
|
|
74
|
+
|
|
75
|
+
def count_by_counts_values(rows):
|
|
76
|
+
assert count_by(rows, "dept") == {"eng": 2, "ops": 1}
|
|
77
|
+
|
|
78
|
+
def aggregate_supports_sum_count_join(rows):
|
|
79
|
+
assert aggregate(rows, "dept", {"salary": "sum", "name": "join", "n": "count"}) == [
|
|
80
|
+
{"dept": "eng", "salary": 220, "name": "Ann,Bob", "n": 2},
|
|
81
|
+
{"dept": "ops", "salary": 90, "name": "Cy", "n": 1},
|
|
82
|
+
]
|
|
83
|
+
assert aggregate(rows, "dept", {"name": "join:;"})[0]["name"] == "Ann;Bob"
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize("agg, eng_value", [
|
|
86
|
+
("mean", 110.0), ("min", 100), ("max", 120),
|
|
87
|
+
("first", 100), ("last", 120),
|
|
88
|
+
(lambda vs: sum(vs), 220),
|
|
89
|
+
])
|
|
90
|
+
def aggregate_reduces_eng_salary(rows, agg, eng_value):
|
|
91
|
+
assert aggregate(rows, "dept", {"salary": agg})[0]["salary"] == eng_value
|
|
92
|
+
|
|
93
|
+
@pytest.mark.parametrize("how, names", [
|
|
94
|
+
("inner", ["a"]),
|
|
95
|
+
("left", ["a", "b"]),
|
|
96
|
+
("right", ["a", None]),
|
|
97
|
+
("outer", ["a", "b", None]),
|
|
98
|
+
])
|
|
99
|
+
def join_modes_match_keys(how, names):
|
|
100
|
+
left = [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]
|
|
101
|
+
right = [{"id": 1, "city": "X"}, {"id": 3, "city": "Z"}]
|
|
102
|
+
assert pluck(join(left, right, on="id", how=how), "name") == names
|
|
103
|
+
|
|
104
|
+
def concat_stacks_and_fills_missing():
|
|
105
|
+
assert concat([{"a": 1}], [{"b": 2}]) == [{"a": 1, "b": None}, {"a": None, "b": 2}]
|
|
106
|
+
|
|
107
|
+
def replace_values_maps_in_place(rows):
|
|
108
|
+
replace_values(rows, "dept", {"eng": "E"})
|
|
109
|
+
assert pluck(rows, "dept") == ["E", "E", "ops"]
|
|
110
|
+
|
|
111
|
+
def map_column_applies_fn_in_place(rows):
|
|
112
|
+
map_column(rows, "salary", lambda v: v + 1)
|
|
113
|
+
assert pluck(rows, "salary") == [101, 121, 91]
|
|
114
|
+
|
|
115
|
+
def describe_summarizes_numeric_column(rows):
|
|
116
|
+
d = describe(rows, "salary")
|
|
117
|
+
assert (d["count"], d["nulls"], d["unique"], d["min"], d["max"]) == (3, 0, 3, 90, 120)
|
|
118
|
+
assert d["mean"] == pytest.approx(103.333, abs=0.01)
|
|
119
|
+
|
|
120
|
+
def describe_counts_nulls_and_skips_non_numeric_mean():
|
|
121
|
+
d = describe([{"v": "x"}, {"v": None}, {"v": "y"}], "v")
|
|
122
|
+
assert (d["count"], d["nulls"], d["unique"], d["mean"]) == (3, 1, 2, None)
|
|
123
|
+
|
|
124
|
+
def markdown_renders_table_with_auto_align():
|
|
125
|
+
data = [{"name": "R1", "value": 10}, {"name": "R2", "value": 22}]
|
|
126
|
+
assert markdown(data) == (
|
|
127
|
+
"| name | value |\n"
|
|
128
|
+
"| :--- | ----: |\n" # numeric column auto-aligns right
|
|
129
|
+
"| R1 | 10 |\n"
|
|
130
|
+
"| R2 | 22 |"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def markdown_raw_renders_list_of_lists():
|
|
134
|
+
raw = [["Name", "Ohm"], ["R1", "10k"], ["R2", "22k"]]
|
|
135
|
+
assert markdown_raw(raw) == (
|
|
136
|
+
"| Name | Ohm |\n"
|
|
137
|
+
"| :--- | :-- |\n"
|
|
138
|
+
"| R1 | 10k |\n"
|
|
139
|
+
"| R2 | 22k |"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def markdown_escapes_pipe_and_handles_empty():
|
|
143
|
+
assert markdown([{"a": "x|y"}]) == "| a |\n| :--- |\n| x\\|y |"
|
|
144
|
+
assert markdown([]) == ""
|