uscript 1.6.4__py3-none-any.whl
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.
- hoodlib/__init__.py +9 -0
- hoodlib/chart.py +167 -0
- hoodlib/disk.py +177 -0
- uarcade/__init__.py +9 -0
- uarcade/cli.py +408 -0
- uarcade/core/runtime.py +148 -0
- uarcade/core/ui.py +373 -0
- uarcade/games/__init__.py +31 -0
- uarcade/games/barbershop.py +143 -0
- uarcade/games/beatlab.py +393 -0
- uarcade/games/bones.py +758 -0
- uarcade/games/ceelo.py +353 -0
- uarcade/games/cookout.py +859 -0
- uarcade/games/cruise.py +429 -0
- uarcade/games/cypher.py +336 -0
- uarcade/games/dropday.py +777 -0
- ufarm/__init__.py +3 -0
- ufarm/cli.py +385 -0
- ufarm/drone.py +158 -0
- ufarm/grid.py +216 -0
- ufarm/levels.py +350 -0
- ufarm/renderer.py +249 -0
- ufarm/runner.py +236 -0
- uscript/__init__.py +26 -0
- uscript/__main__.py +6 -0
- uscript/_version.py +24 -0
- uscript/cache.py +36 -0
- uscript/cli.py +948 -0
- uscript/constants.py +74 -0
- uscript/data/curriculum.md +345 -0
- uscript/data/icon.png +0 -0
- uscript/data/solutions/barbershop/stage1.us +6 -0
- uscript/data/solutions/barbershop/stage2.us +7 -0
- uscript/data/solutions/barbershop/stage3.us +4 -0
- uscript/data/solutions/beatlab/stage1.us +10 -0
- uscript/data/solutions/beatlab/stage2.us +15 -0
- uscript/data/solutions/beatlab/stage3.us +11 -0
- uscript/data/solutions/beatlab/stage4.us +14 -0
- uscript/data/solutions/beatlab/stage5.us +13 -0
- uscript/data/solutions/bones/stage1.us +11 -0
- uscript/data/solutions/bones/stage2.us +12 -0
- uscript/data/solutions/bones/stage3.us +17 -0
- uscript/data/solutions/bones/stage4.us +34 -0
- uscript/data/solutions/bones/stage5.us +93 -0
- uscript/data/solutions/ceelo/stage1.us +14 -0
- uscript/data/solutions/ceelo/stage2.us +23 -0
- uscript/data/solutions/ceelo/stage3.us +29 -0
- uscript/data/solutions/ceelo/stage4.us +4 -0
- uscript/data/solutions/ceelo/stage5.us +27 -0
- uscript/data/solutions/cookout/stage1.us +9 -0
- uscript/data/solutions/cookout/stage2.us +8 -0
- uscript/data/solutions/cookout/stage3.us +5 -0
- uscript/data/solutions/cookout/stage4.us +26 -0
- uscript/data/solutions/cookout/stage5.us +13 -0
- uscript/data/solutions/cruise/stage1.us +13 -0
- uscript/data/solutions/cruise/stage2.us +12 -0
- uscript/data/solutions/cruise/stage3.us +7 -0
- uscript/data/solutions/cruise/stage4.us +22 -0
- uscript/data/solutions/cruise/stage5.us +28 -0
- uscript/data/solutions/cypher/stage1.us +16 -0
- uscript/data/solutions/cypher/stage2.us +24 -0
- uscript/data/solutions/cypher/stage3.us +23 -0
- uscript/data/solutions/cypher/stage4.us +25 -0
- uscript/data/solutions/cypher/stage5.us +65 -0
- uscript/data/solutions/dropday/stage1.us +13 -0
- uscript/data/solutions/dropday/stage2.us +14 -0
- uscript/data/solutions/dropday/stage3.us +12 -0
- uscript/data/solutions/dropday/stage4.us +11 -0
- uscript/data/solutions/dropday/stage5.us +16 -0
- uscript/data/solutions/level1.us +9 -0
- uscript/data/solutions/level2.us +9 -0
- uscript/data/solutions/level3.us +14 -0
- uscript/data/solutions/level4.us +18 -0
- uscript/data/solutions/level5.us +38 -0
- uscript/data/solutions/level6.us +14 -0
- uscript/data/solutions/level7.us +37 -0
- uscript/data/solutions/level8.us +41 -0
- uscript/debugger.py +71 -0
- uscript/doctor.py +247 -0
- uscript/errors/__init__.py +14 -0
- uscript/errors/catalog.py +371 -0
- uscript/errors/renderer.py +85 -0
- uscript/errors/translator.py +240 -0
- uscript/formatter.py +78 -0
- uscript/ide/__init__.py +1 -0
- uscript/ide/analysis.py +280 -0
- uscript/ide/server.py +1195 -0
- uscript/importer.py +40 -0
- uscript/kernel/__init__.py +12 -0
- uscript/kernel/__main__.py +13 -0
- uscript/kernel/installer.py +54 -0
- uscript/kernel/kernel.py +296 -0
- uscript/kernel/magics.py +104 -0
- uscript/linter.py +369 -0
- uscript/lsp.py +169 -0
- uscript/migrate.py +146 -0
- uscript/migrator.py +31 -0
- uscript/patterns.py +487 -0
- uscript/platform.py +227 -0
- uscript/repl.py +177 -0
- uscript/replay.py +325 -0
- uscript/sandbox.py +215 -0
- uscript/sourcing.py +416 -0
- uscript/steam.py +369 -0
- uscript/stubs.py +16 -0
- uscript/traceback_handler.py +63 -0
- uscript/transpiler.py +58 -0
- uscript/tutor.py +550 -0
- uscript/versioning.py +44 -0
- uscript/zen.py +60 -0
- uscript-1.6.4.dist-info/METADATA +242 -0
- uscript-1.6.4.dist-info/RECORD +116 -0
- uscript-1.6.4.dist-info/WHEEL +4 -0
- uscript-1.6.4.dist-info/entry_points.txt +8 -0
- uscript-1.6.4.dist-info/licenses/LICENSE +3 -0
- vibe.py +8 -0
hoodlib/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""hoodlib — the small standard library that ships with U-Script.
|
|
2
|
+
|
|
3
|
+
Only what Python's own library doesn't already give you in one call. Everything
|
|
4
|
+
here is standard-library Python: no dependencies, importable from .us or .py.
|
|
5
|
+
|
|
6
|
+
from hoodlib import chart, disk
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__all__ = ["chart", "disk"]
|
hoodlib/chart.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""hoodlib.chart — plots that fit in a terminal, standard library only.
|
|
2
|
+
|
|
3
|
+
from hoodlib import chart
|
|
4
|
+
holla chart.sparkline([1, 5, 2, 8, 3]) # ⣰⣸⡄ (Braille: 2 points per cell, 4 levels)
|
|
5
|
+
holla chart.bar_chart({"corn": 12, "beans": 7}, title="Bag")
|
|
6
|
+
holla chart.histogram(samples, bins=8)
|
|
7
|
+
|
|
8
|
+
Everything returns a string; nothing writes to the terminal for you, so the
|
|
9
|
+
output can be logged, tested, or `holla`'d. Colour is off unless you ask
|
|
10
|
+
(`color=True`), and then it is plain ANSI, no dependency.
|
|
11
|
+
|
|
12
|
+
Errors are the Python exceptions the crash reporter already explains: an empty
|
|
13
|
+
series is a `ValueError` (BadValueTrip) that says so; a non-number is a
|
|
14
|
+
`TypeError` (WrongKindTrip) that names the offending value.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
import shutil
|
|
21
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
22
|
+
|
|
23
|
+
__all__ = ["sparkline", "bar_chart", "histogram", "show_bag", "BLOCKS"]
|
|
24
|
+
|
|
25
|
+
# Braille cell: 2 columns × 4 rows. Bits, bottom-up, per column.
|
|
26
|
+
_LEFT = (0x40, 0x04, 0x02, 0x01) # dots 7, 3, 2, 1
|
|
27
|
+
_RIGHT = (0x80, 0x20, 0x10, 0x08) # dots 8, 6, 5, 4
|
|
28
|
+
BLOCKS = "▁▂▃▄▅▆▇█" # the 1-column-per-point alternative
|
|
29
|
+
|
|
30
|
+
_RESET = "\x1b[0m"
|
|
31
|
+
_PALETTE = ("\x1b[36m", "\x1b[32m", "\x1b[33m", "\x1b[35m", "\x1b[34m", "\x1b[31m")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _numbers(data: Iterable[object], what: str) -> list[float]:
|
|
35
|
+
out: list[float] = []
|
|
36
|
+
for i, v in enumerate(data):
|
|
37
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
38
|
+
raise TypeError(f"{what} needs numbers, but item {i} is {v!r} ({type(v).__name__})")
|
|
39
|
+
f = float(v)
|
|
40
|
+
if not math.isfinite(f):
|
|
41
|
+
raise ValueError(f"{what} can't plot {v!r} (item {i}): every value must be a finite number")
|
|
42
|
+
out.append(f)
|
|
43
|
+
if not out:
|
|
44
|
+
raise ValueError(f"{what} got an empty series; there is nothing to plot")
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _levels(values: Sequence[float], steps: int) -> list[int]:
|
|
49
|
+
"""Scale each value to 1..steps: the minimum still shows one dot, so every
|
|
50
|
+
point is visible. A flat series sits in the middle."""
|
|
51
|
+
lo, hi = min(values), max(values)
|
|
52
|
+
if hi == lo:
|
|
53
|
+
return [(steps + 1) // 2 for _ in values]
|
|
54
|
+
span = hi - lo
|
|
55
|
+
return [1 + min(steps - 1, int(round((v - lo) / span * (steps - 1)))) for v in values]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _term_width(default: int = 80) -> int:
|
|
59
|
+
try:
|
|
60
|
+
return shutil.get_terminal_size((default, 24)).columns
|
|
61
|
+
except (AttributeError, ValueError, OSError):
|
|
62
|
+
return default
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _paint(text: str, i: int, color: bool) -> str:
|
|
66
|
+
return f"{_PALETTE[i % len(_PALETTE)]}{text}{_RESET}" if color else text
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --------------------------------------------------------------------------- sparkline
|
|
70
|
+
|
|
71
|
+
def sparkline(data: Iterable[object], *, style: str = "braille", width: int | None = None) -> str:
|
|
72
|
+
"""One line for a series. `braille` packs two points per character on a
|
|
73
|
+
4-dot scale (so 80 columns show 160 points); `blocks` uses one ▁▂▃ block
|
|
74
|
+
per point. `width` caps the output in characters; the series is resampled
|
|
75
|
+
(by picking evenly spaced points) rather than cut off."""
|
|
76
|
+
values = _numbers(data, "sparkline()")
|
|
77
|
+
if style not in ("braille", "blocks"):
|
|
78
|
+
raise ValueError(f"sparkline() style must be 'braille' or 'blocks', not {style!r}")
|
|
79
|
+
per_char = 2 if style == "braille" else 1
|
|
80
|
+
if width is not None:
|
|
81
|
+
if width < 1:
|
|
82
|
+
raise ValueError("sparkline() width must be at least 1 character")
|
|
83
|
+
cap = width * per_char
|
|
84
|
+
if len(values) > cap:
|
|
85
|
+
step = (len(values) - 1) / (cap - 1) if cap > 1 else 0
|
|
86
|
+
values = [values[int(round(k * step))] for k in range(cap)]
|
|
87
|
+
if style == "blocks":
|
|
88
|
+
return "".join(BLOCKS[n - 1] for n in _levels(values, len(BLOCKS)))
|
|
89
|
+
levels = _levels(values, 4)
|
|
90
|
+
if len(levels) % 2:
|
|
91
|
+
levels.append(-1) # odd count: last cell's right column stays empty
|
|
92
|
+
cells = []
|
|
93
|
+
for a, b in zip(levels[0::2], levels[1::2], strict=True):
|
|
94
|
+
bits = 0
|
|
95
|
+
for k in range(a):
|
|
96
|
+
bits |= _LEFT[k]
|
|
97
|
+
for k in range(max(b, 0)):
|
|
98
|
+
bits |= _RIGHT[k]
|
|
99
|
+
cells.append(chr(0x2800 + bits))
|
|
100
|
+
return "".join(cells)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------------------- bar chart
|
|
104
|
+
|
|
105
|
+
def bar_chart(values: Mapping[str, object], *, title: str | None = None, width: int | None = None,
|
|
106
|
+
color: bool = False, sort: bool = False, fill: str = "█") -> str:
|
|
107
|
+
"""Horizontal bars, one per key, labels left-aligned and values shown at
|
|
108
|
+
the end of each bar. Bars scale to the longest so the chart fits `width`
|
|
109
|
+
columns (default: the terminal). Negative values draw an empty bar with
|
|
110
|
+
the number, rather than pretending they are zero."""
|
|
111
|
+
if not values:
|
|
112
|
+
raise ValueError("bar_chart() got an empty mapping; there is nothing to plot")
|
|
113
|
+
nums = _numbers(values.values(), "bar_chart()")
|
|
114
|
+
items = list(zip((str(k) for k in values), nums, strict=True))
|
|
115
|
+
if sort:
|
|
116
|
+
items.sort(key=lambda kv: kv[1], reverse=True)
|
|
117
|
+
width = width or _term_width()
|
|
118
|
+
label_w = max(len(k) for k, _ in items)
|
|
119
|
+
num_strs = [_fmt(v) for _, v in items]
|
|
120
|
+
num_w = max(len(s) for s in num_strs)
|
|
121
|
+
bar_w = max(4, width - label_w - num_w - 4) # "label │bar value"
|
|
122
|
+
top = max(v for _, v in items)
|
|
123
|
+
lines = []
|
|
124
|
+
if title:
|
|
125
|
+
lines.append(title)
|
|
126
|
+
lines.append("─" * min(width, max(len(title), label_w + bar_w + num_w + 4)))
|
|
127
|
+
for i, ((label, v), s) in enumerate(zip(items, num_strs, strict=True)):
|
|
128
|
+
n = int(round(v / top * bar_w)) if top > 0 and v > 0 else 0
|
|
129
|
+
bar = _paint(fill * n, i, color)
|
|
130
|
+
lines.append(f"{label.ljust(label_w)} │{bar}{' ' * (bar_w - n)} {s.rjust(num_w)}")
|
|
131
|
+
return "\n".join(lines)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _fmt(v: float) -> str:
|
|
135
|
+
return str(int(v)) if v == int(v) else f"{v:.2f}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --------------------------------------------------------------------------- histogram
|
|
139
|
+
|
|
140
|
+
def histogram(data: Iterable[object], *, bins: int = 10, width: int | None = None,
|
|
141
|
+
color: bool = False, fill: str = "█") -> str:
|
|
142
|
+
"""Counts per equal-width bin, drawn as a bar chart whose labels are the bin
|
|
143
|
+
ranges. `bins` must be at least 1; a flat series gets one bin."""
|
|
144
|
+
values = _numbers(data, "histogram()")
|
|
145
|
+
if bins < 1:
|
|
146
|
+
raise ValueError("histogram() needs at least 1 bin")
|
|
147
|
+
lo, hi = min(values), max(values)
|
|
148
|
+
if hi == lo:
|
|
149
|
+
return bar_chart({f"{_fmt(lo)}": len(values)}, width=width, color=color, fill=fill)
|
|
150
|
+
edges = [lo + (hi - lo) * k / bins for k in range(bins + 1)]
|
|
151
|
+
counts = [0] * bins
|
|
152
|
+
for v in values:
|
|
153
|
+
k = min(bins - 1, int((v - lo) / (hi - lo) * bins))
|
|
154
|
+
counts[k] += 1
|
|
155
|
+
labels = {f"{_fmt(edges[k])}–{_fmt(edges[k + 1])}": counts[k] for k in range(bins)}
|
|
156
|
+
return bar_chart(labels, width=width, color=color, fill=fill)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --------------------------------------------------------------------------- games
|
|
160
|
+
|
|
161
|
+
def show_bag(bag: Mapping[str, object], *, title: str = "Bag", **kw: object) -> str:
|
|
162
|
+
"""The farm drone's `bot.bag` (or any inventory mapping) as a bar chart.
|
|
163
|
+
An empty bag is a line saying so, not an error — an empty inventory is a
|
|
164
|
+
normal game state."""
|
|
165
|
+
if not bag:
|
|
166
|
+
return f"{title}: empty"
|
|
167
|
+
return bar_chart(bag, title=title, **kw) # type: ignore[arg-type]
|
hoodlib/disk.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""hoodlib.disk — files that don't get corrupted.
|
|
2
|
+
|
|
3
|
+
Atomic, thread-safe JSON and CSV read/write with backups. Every write goes to a
|
|
4
|
+
temp file in the same directory, is flushed and fsynced, then renamed over the
|
|
5
|
+
target (os.replace is atomic on every platform Python supports), so a crash or
|
|
6
|
+
a second writer can never leave a half-written file. One lock per path keeps
|
|
7
|
+
threads in one process from interleaving.
|
|
8
|
+
|
|
9
|
+
Errors are the Python exceptions U-Script already explains: FileNotFoundError
|
|
10
|
+
(MissingFileTrip), PermissionError (NoAccessTrip), ValueError (BadValueTrip) for
|
|
11
|
+
malformed content.
|
|
12
|
+
|
|
13
|
+
from hoodlib import disk
|
|
14
|
+
disk.write_json("scores.json", {"khris": 42})
|
|
15
|
+
data = disk.read_json("scores.json", default={})
|
|
16
|
+
disk.append_csv("log.csv", ["2026-09-12", "win"], header=["date", "result"])
|
|
17
|
+
rows = disk.read_csv("log.csv") # list of dicts
|
|
18
|
+
disk.backup("scores.json") # scores.json.2026-09-12T10-30-00.bak
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import csv
|
|
24
|
+
import io
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
import threading
|
|
29
|
+
from collections.abc import Iterable, Sequence
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
__all__ = ["read_json", "write_json", "read_csv", "write_csv", "append_csv", "write_text", "backup", "atomic_write"]
|
|
35
|
+
|
|
36
|
+
_LOCKS: dict[str, threading.Lock] = {}
|
|
37
|
+
_LOCKS_GUARD = threading.Lock()
|
|
38
|
+
_MISSING = object()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _lock_for(path: Path) -> threading.Lock:
|
|
42
|
+
key = str(path.resolve())
|
|
43
|
+
with _LOCKS_GUARD:
|
|
44
|
+
lock = _LOCKS.get(key)
|
|
45
|
+
if lock is None:
|
|
46
|
+
lock = _LOCKS[key] = threading.Lock()
|
|
47
|
+
return lock
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def atomic_write(path: str | os.PathLike[str], data: str | bytes, *, encoding: str = "utf-8") -> Path:
|
|
51
|
+
"""Write `data` to `path` atomically: readers see the old file or the new one, never a mix."""
|
|
52
|
+
target = Path(path)
|
|
53
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
payload = data.encode(encoding) if isinstance(data, str) else data
|
|
55
|
+
with _lock_for(target):
|
|
56
|
+
tmp = target.with_name(f".{target.name}.tmp.{os.getpid()}.{threading.get_ident()}")
|
|
57
|
+
try:
|
|
58
|
+
with open(tmp, "wb") as fh:
|
|
59
|
+
fh.write(payload)
|
|
60
|
+
fh.flush()
|
|
61
|
+
os.fsync(fh.fileno())
|
|
62
|
+
os.replace(tmp, target)
|
|
63
|
+
finally:
|
|
64
|
+
if tmp.exists():
|
|
65
|
+
tmp.unlink(missing_ok=True)
|
|
66
|
+
return target
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def write_text(path: str | os.PathLike[str], text: str, *, encoding: str = "utf-8") -> Path:
|
|
70
|
+
"""Atomic text write."""
|
|
71
|
+
return atomic_write(path, text, encoding=encoding)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def read_json(path: str | os.PathLike[str], default: Any = _MISSING) -> Any:
|
|
75
|
+
"""Read a JSON file. With `default`, a missing file returns it instead of raising.
|
|
76
|
+
Malformed JSON raises ValueError naming the file and the position."""
|
|
77
|
+
target = Path(path)
|
|
78
|
+
with _lock_for(target):
|
|
79
|
+
try:
|
|
80
|
+
text = target.read_text(encoding="utf-8")
|
|
81
|
+
except FileNotFoundError:
|
|
82
|
+
if default is not _MISSING:
|
|
83
|
+
return default
|
|
84
|
+
raise
|
|
85
|
+
try:
|
|
86
|
+
return json.loads(text)
|
|
87
|
+
except json.JSONDecodeError as e:
|
|
88
|
+
raise ValueError(f"{target.name} is not valid JSON: {e.msg} (line {e.lineno}, column {e.colno})") from None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def write_json(path: str | os.PathLike[str], data: Any, *, indent: int = 2, sort_keys: bool = False) -> Path:
|
|
92
|
+
"""Serialize `data` and write it atomically. Non-JSON values raise ValueError
|
|
93
|
+
naming the offending type, not Python's `TypeError: Object of type X is not
|
|
94
|
+
JSON serializable`."""
|
|
95
|
+
try:
|
|
96
|
+
text = json.dumps(data, indent=indent, sort_keys=sort_keys, ensure_ascii=False) + "\n"
|
|
97
|
+
except TypeError as e:
|
|
98
|
+
raise ValueError(f"can't save this to JSON: {e}") from None
|
|
99
|
+
return atomic_write(path, text)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_csv(path: str | os.PathLike[str], *, header: bool = True) -> list[dict[str, str]] | list[list[str]]:
|
|
103
|
+
"""Read a CSV file: a list of dicts when it has a header row (default), else a
|
|
104
|
+
list of lists. A missing file raises FileNotFoundError."""
|
|
105
|
+
target = Path(path)
|
|
106
|
+
with _lock_for(target), open(target, encoding="utf-8", newline="") as fh: # newline="" per the csv docs
|
|
107
|
+
text = fh.read()
|
|
108
|
+
if header:
|
|
109
|
+
return list(csv.DictReader(io.StringIO(text)))
|
|
110
|
+
return list(csv.reader(io.StringIO(text)))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def write_csv(path: str | os.PathLike[str], rows: Iterable[Sequence[Any] | dict[str, Any]],
|
|
114
|
+
*, header: Sequence[str] | None = None) -> Path:
|
|
115
|
+
"""Write rows atomically. Rows may be sequences or dicts; with dicts the header is
|
|
116
|
+
taken from the first row's keys when not given."""
|
|
117
|
+
rows = list(rows)
|
|
118
|
+
buf = io.StringIO(newline="")
|
|
119
|
+
if rows and isinstance(rows[0], dict):
|
|
120
|
+
fields = list(header) if header is not None else list(rows[0].keys())
|
|
121
|
+
writer = csv.DictWriter(buf, fieldnames=fields, lineterminator="\n")
|
|
122
|
+
writer.writeheader()
|
|
123
|
+
for row in rows:
|
|
124
|
+
if not isinstance(row, dict):
|
|
125
|
+
raise ValueError("write_csv: mixed dict and list rows")
|
|
126
|
+
writer.writerow(row)
|
|
127
|
+
else:
|
|
128
|
+
writer = csv.writer(buf, lineterminator="\n")
|
|
129
|
+
if header is not None:
|
|
130
|
+
writer.writerow(list(header))
|
|
131
|
+
for row in rows:
|
|
132
|
+
if isinstance(row, dict):
|
|
133
|
+
raise ValueError("write_csv: mixed dict and list rows")
|
|
134
|
+
writer.writerow(list(row))
|
|
135
|
+
return atomic_write(path, buf.getvalue())
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def append_csv(path: str | os.PathLike[str], row: Sequence[Any] | dict[str, Any],
|
|
139
|
+
*, header: Sequence[str] | None = None) -> Path:
|
|
140
|
+
"""Append one row. Creates the file (with `header`, if given) when it doesn't
|
|
141
|
+
exist. The whole file is rewritten atomically, so a crash mid-append leaves
|
|
142
|
+
the previous complete file, never a torn line."""
|
|
143
|
+
target = Path(path)
|
|
144
|
+
existing: list[list[str]] = []
|
|
145
|
+
if target.exists():
|
|
146
|
+
existing = read_csv(target, header=False) # type: ignore[assignment]
|
|
147
|
+
elif header is not None:
|
|
148
|
+
existing = [list(header)]
|
|
149
|
+
if isinstance(row, dict):
|
|
150
|
+
fields = existing[0] if existing else (list(header) if header else list(row.keys()))
|
|
151
|
+
if not existing:
|
|
152
|
+
existing = [list(fields)]
|
|
153
|
+
existing.append([str(row.get(f, "")) for f in fields])
|
|
154
|
+
else:
|
|
155
|
+
existing.append([str(v) for v in row])
|
|
156
|
+
return write_csv(target, existing)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def backup(path: str | os.PathLike[str], *, keep: int | None = None) -> Path:
|
|
160
|
+
"""Copy `path` to `<name>.<timestamp>.bak` next to it and return the copy.
|
|
161
|
+
With `keep=N`, only the N newest backups of that file are retained."""
|
|
162
|
+
target = Path(path)
|
|
163
|
+
if not target.exists():
|
|
164
|
+
raise FileNotFoundError(f"[Errno 2] No such file or directory: '{target}'")
|
|
165
|
+
stamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
|
166
|
+
dest = target.with_name(f"{target.name}.{stamp}.bak")
|
|
167
|
+
n = 0
|
|
168
|
+
with _lock_for(target):
|
|
169
|
+
while dest.exists():
|
|
170
|
+
n += 1
|
|
171
|
+
dest = target.with_name(f"{target.name}.{stamp}-{n}.bak")
|
|
172
|
+
shutil.copy2(target, dest)
|
|
173
|
+
if keep is not None:
|
|
174
|
+
old = sorted(target.parent.glob(f"{target.name}.*.bak"), key=lambda p: p.stat().st_mtime)
|
|
175
|
+
for stale in old[:-keep] if keep > 0 else old:
|
|
176
|
+
stale.unlink(missing_ok=True)
|
|
177
|
+
return dest
|
uarcade/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""U-Script Cultural Arcade Engine (uarcade).
|
|
2
|
+
|
|
3
|
+
Gamified terminal programming learning suite celebrating Black pop culture:
|
|
4
|
+
- Big Mama's Cookout: The Spades & Grill Protocol
|
|
5
|
+
- BeatLab 808: The MPC Matrix
|
|
6
|
+
- Drop Day: Sole Runner & Deadstock Logistics
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "1.0.0"
|