1 A table, and a tool for the docs

Build it yourself. Read the tutorial is the other way to read this part.

If you start at this part, make your clone as Get the project says, and then run this command. It puts in your clone the files that this part starts from. A file that the project deleted before this part stays, so delete it if git check lists it:

git rm -rq --cached --ignore-unmatch -- :/ && git restore --source 974d653080b0 --staged --worktree -- :/
just setup

A list of counts is hard to read at a glance. A table lines up the words and the counts. A small package, tabulate, draws tables, so tally depends on it. The docs come later, and their tool comes now, in a group of its own.

The code before this part. The code after it.

1.1 tabulate, in a range of versions

tally needs tabulate to draw a table. A new version 1.0 of tabulate could change how it draws one, so tally takes any 0.9 version, and no later one. uv add with a range of versions puts the range in pyproject.toml, and the exact version in uv.lock:

uv add "tabulate>=0.9,<0.10"
uv run python -c "import tabulate; print(tabulate.__version__)"

It prints 0.9.0.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check 418e7fd88a3f

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git restore --source 974d653080b0 --staged -- :/ && git restore --source 418e7fd88a3f --staged --worktree -- :/

What changed

  • pyproject.toml: tabulate, with the range of its versions. 3 lines added, 1 removed. It comes from uv add "tabulate>=0.9,<0.10". The file.
    • look at "tabulate>=0.9,<0.10",
  • uv.lock: the exact version of tabulate. 13 lines added. It comes from uv add "tabulate>=0.9,<0.10". The file.
The diff that uv add "tabulate>=0.9,<0.10" makes in pyproject.toml
@@ -4,7 +4,9 @@ version = "0.1.0"
 description = "Count the words in a text"
 readme = "README.md"
 requires-python = ">=3.12"
-dependencies = []
+dependencies = [
+    "tabulate>=0.9,<0.10",
+]
 
 [project.scripts]
 tally = "tally:main"

This change on GitHub. The code after it.

1.2 tally --table

tally prints one line for each word, with the count first. A new option, --table, prints the same counts as a table, with a header. main takes the option out of the arguments, and passes the rows to tabulate.

Apply the patch to src/tally/__init__.py, from the top of the project:

curl -fsSL https://github.com/rahuldave/tally/commit/5051c3ff6e71020d5458c29a2e4a1f358d361b16.diff | git apply --include=src/tally/__init__.py

The patch adds from tabulate import tabulate under import sys, after a blank line. In main, it adds the flag as_table, and prints the rows with tabulate when the flag is set.

uv run tally --top 3 --table data/corpus.txt

It prints a header, word and times, a line of dashes, and the three words with their counts in a column on the right.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check 5051c3ff6e71

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git restore --source 418e7fd88a3f --staged -- :/ && git restore --source 5051c3ff6e71 --staged --worktree -- :/

What changed

  • src/tally/__init__.py: the option --table, which prints the rows with tabulate. 11 lines added, 3 removed. The file.
    • look at print(tabulate(rows, headers=["word", "times"]))
The patch to src/tally/__init__.py
@@ -2,6 +2,8 @@
 
 import sys
 
+from tabulate import tabulate
+
 
 def count(text: str) -> dict[str, int]:
     "How many times each word of `text` appears, ignoring case."
@@ -17,11 +19,17 @@ def top(counts: dict[str, int], n: int) -> list[tuple[str, int]]:
 
 
 def main() -> None:
-    "Count the words of a file, or of the standard input. With --top N, only the N most common."
-    args, n = sys.argv[1:], None
+    "Count the words of a file, or of the standard input. Options: --top N, and --table."
+    args = sys.argv[1:]
+    as_table = "--table" in args
+    args, n = [arg for arg in args if arg != "--table"], None
     if args[:1] == ["--top"]:
         n, args = int(args[1]), args[2:]
     text = open(args[0]).read() if args else sys.stdin.read()
     counts = count(text)
-    for word, times in top(counts, n) if n else sorted(counts.items()):
+    rows = top(counts, n) if n else sorted(counts.items())
+    if as_table:
+        print(tabulate(rows, headers=["word", "times"]))
+        return
+    for word, times in rows:
         print(times, word)

This change on GitHub. The code after it.

1.3 mdformat, in the group docs

The docs will be Markdown files. mdformat formats Markdown, and its option --check says whether a file is formatted. Only the people who write the docs need it, so it goes in a group of its own, docs:

uv add --group docs mdformat

uv sync installs the group dev and no other group, unless the project names more. One more line in the table [tool.uv] of pyproject.toml names both groups, so that just setup installs mdformat too.

After uv add --group docs mdformat, set the other values that this change sets in pyproject.toml. Run this command from the top of the project. It reads your file with tomlkit, a library for TOML files. It sets each of these values as the project has it, and keeps the rest of your file:

uv run --quiet --no-config --no-project --with tomlkit python - <<'PY'
import pathlib, tomlkit
path = pathlib.Path('pyproject.toml')
doc = tomlkit.parse(path.read_text(encoding='utf-8')) if path.exists() else tomlkit.document()
# Each value that the change sets, by its keys
SET = [
    (('tool', 'uv'), {'default-groups': ['dev', 'docs']}),
]

def put(table, keys, value):   # make each table on the way; a table goes into the one there, key by key
    for key in keys[:-1]:
        if key not in table:
            table[key] = tomlkit.table(True)
        table = table[key]
    if isinstance(value, dict) and isinstance(table.get(keys[-1]), dict):
        for key, item in value.items():
            put(table[keys[-1]], [key], item)
    else:
        table[keys[-1]] = value

for keys, value in SET:
    put(doc, keys, value)
path.write_text(tomlkit.dumps(doc), encoding='utf-8')
PY

After uv add, the patch adds a table [tool.uv] at the end of pyproject.toml, with the line default-groups.

uv sync -q && uv run --no-sync mdformat --version

uv sync installs mdformat. Then uv run --no-sync runs it without another sync, and it prints mdformat 1.0.0, or a newer version.

Check your files against this move. The command prints nothing when they match, and otherwise shows what differs:

git check ec6a8aa7f9a8

If you are stuck, this command puts the files of this move in place of yours. A file that you made yourself stays:

git restore --source 5051c3ff6e71 --staged -- :/ && git restore --source ec6a8aa7f9a8 --staged --worktree -- :/

What changed

  • pyproject.toml: the group docs, and the groups that uv sync installs. 6 lines added. It comes from uv add --group docs mdformat. The file.
    • look at default-groups = ["dev", "docs"]
  • uv.lock: the exact versions of mdformat and of the packages it needs. 37 lines added. It comes from uv add --group docs mdformat. The file.
After uv add --group docs mdformat, the patch to pyproject.toml
@@ -25,3 +25,6 @@ docs = [
 
 [tool.ruff]
 line-length = 100
+
+[tool.uv]
+default-groups = ["dev", "docs"]

This change on GitHub. The code after it.