tally, one move at a time
We build tally, a small word counter, one change at a time. It begins as a command with nothing to count. At the end, it counts the words of a file, has tests and types, and writes a report of the most common words. Every command runs through uv, in the project's own environment.
We make tally in moves. A move is one small change, made by one patch or by one command. A patch changes a file, and
a command such as uv add writes the files it needs.
Each move first says what problem it solves. Then it gives the change, piece by piece. Last, a command checks the result, and a list names each file that changed, with what to look for in it.
There are two ways to read this tutorial. Both have the same parts, in the same order.
Each part ends with a section called What changed. It lists every file that the part changes, with a link to the file, and then the whole diff of each file, folded. A lockfile and a binary file have a link alone. The text above it explains the change. In Build it yourself, What changed is what you check your files against.
In Build it yourself, a file that a command makes says which command. A diff that is open is a patch. Apply it with the command in the text, or make the same change in your editor.
Read the tutorial
Each change explained, with the code to look at.
Build it yourself
Every command to run and every change to make by hand, to make the project yourself.
Get the project
You build the project in a clone of your own, on a branch mine that starts where this walk starts. Run the commands of these pages in its top folder.
git clone https://github.com/rahuldave/tally
cd tally
git switch -c mine 6a63758166d3Then add the command git check, which compares your files with the project's after each move, and leaves what git add has staged as it was. It compares pyproject.toml and uv.lock by the names of their packages, so another version of a package does not count as a difference. A small Python program, package_names.py, reads each of these files as data, and uv runs it. These commands write the program into the folder .git, and tell git to show each file as the program prints it.
cat > "$(git rev-parse --git-common-dir)/package_names.py" <<'PY'
"""Print a pyproject.toml or a uv.lock without the versions of its packages, so that git check compares their names.
python package_names.py pyproject.toml FILE
python package_names.py uv.lock FILE
A pyproject.toml keeps all it says, with each requirement cut to its name, its extras, its address and its marker. A
uv.lock keeps the names that the project's own packages ask for, and the members of the workspace. The rules are those
of timewalk's package_names. The result is JSON, one item a line. A file that is not TOML is printed as it is.
"""
import json
import re
import sys
import tomllib
# A requirement such as "pytest>=8.3.4", "Uvicorn[standard] ; python_version < '3.12'" or "x @ git+https://...": its name,
# its extras, what comes between (the versions, or "@" and an address), and its marker after the ";".
REQUIREMENT = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?([^;]*)(?:;(.*))?$", re.S)
def package_names(
path: str, # The file's path in the project; its name says how to read it
data: bytes, # Its contents
) -> object | None: # What it says without the versions of packages, to compare; None if it is not a file of packages
"""Read a pyproject.toml or a uv.lock with the versions left out, as timewalk's package_names does."""
name = path.rsplit("/", 1)[-1]
if name not in ("pyproject.toml", "uv.lock"):
return None
try:
doc = tomllib.loads(data.decode("utf-8"))
except (UnicodeDecodeError, tomllib.TOMLDecodeError):
return None
def cut(requirement: object) -> object: # "pytest>=8.3.4" is pytest; a table, such as { include-group = "test" }, stays
found = REQUIREMENT.match(requirement) if isinstance(requirement, str) else None
if not found:
return requirement
extras = sorted(e.strip().lower() for e in (found.group(2) or "[]")[1:-1].split(",") if e.strip())
between, marker = found.group(3).strip(), " ".join((found.group(4) or "").split())
address = between[1:].strip() if between.startswith("@") else "" # a package from an address: the address counts
return (re.sub(r"[-_.]+", "-", found.group(1)).lower() + (f"[{','.join(extras)}]" if extras else "")
+ (f" @ {address}" if address else "") + (f"; {marker}" if marker else ""))
def names(value: object) -> list: # every name = "..." in a table, at any depth
if isinstance(value, dict):
return [*([value["name"]] if isinstance(value.get("name"), str) else []), *(n for v in value.values() for n in names(v))]
return [n for v in value for n in names(v)] if isinstance(value, list) else []
if name == "uv.lock":
own = [p for p in doc.get("package", []) if isinstance(p, dict) and isinstance(p.get("source"), dict)
and {"editable", "virtual"} & set(p["source"])]
return {"requires-python": doc.get("requires-python"), "manifest": sorted(set(names(doc.get("manifest", {})))),
"own": {p.get("name"): sorted(set(names({k: v for k, v in p.items() if k not in ("name", "version", "source")})))
for p in own}}
lists = [("project", "dependencies"), ("build-system", "requires"), ("tool", "uv", "dev-dependencies")]
groups = [("project", "optional-dependencies"), ("dependency-groups",)]
def at(keys: tuple) -> object: # the table or list at a path of keys, or None
value = doc
for key in keys:
value = value.get(key) if isinstance(value, dict) else None
return value
for keys in lists:
if isinstance(at(keys), list):
at(keys[:-1])[keys[-1]] = sorted(map(cut, at(keys)), key=repr)
for keys in groups:
table = at(keys)
for group, wanted in (table.items() if isinstance(table, dict) else []):
if isinstance(wanted, list):
table[group] = sorted(map(cut, wanted), key=repr)
return doc
def main() -> int: # The exit code
"""Print a file as package_names reads it, or as it is when it is not TOML."""
kind, file = sys.argv[1], sys.argv[2]
with open(file, "rb") as handle:
data = handle.read()
found = package_names(kind, data)
if found is None:
sys.stdout.buffer.write(data)
else:
print(json.dumps(found, indent=1, sort_keys=True, ensure_ascii=False, default=str))
return 0
if __name__ == "__main__":
sys.exit(main())
PY
echo '/pyproject.toml diff=pyproject' >> .git/info/attributes
echo '/uv.lock diff=uvlock' >> .git/info/attributes
git config diff.pyproject.textconv 'uv run --quiet --no-config --no-project --python ">=3.11" python "$(git rev-parse --git-common-dir)/package_names.py" pyproject.toml'
git config diff.uvlock.textconv 'uv run --quiet --no-config --no-project --python ">=3.11" python "$(git rev-parse --git-common-dir)/package_names.py" uv.lock'
git config alias.check '!f() { i="$(git rev-parse --git-path check-index)" && { cp -p "$(git rev-parse --git-path index)" "$i" 2>/dev/null || rm -f "$i"; } && export GIT_INDEX_FILE="$i" && git add -A && git diff --cached "$1" -- ":/"; }; f'