Skip to content

JSON CLI Tool

Build a command-line tool that reads a JSON array of developer objects from stdin and prints a human-readable summary. It’s your “Hello World” for modern Python — but actually useful, and it exercises a real uv project end to end: project init, the standard library, dataclasses, stdin, and a few collection idioms.

  • Creating a uv project from scratch (uv init, uv run)
  • Reading stdin, writing stdout
  • Modeling data with dataclasses
  • Parsing JSON with the stdlib json module (no third-party deps)
  • collections.defaultdict, statistics.mean, min/max with a key, f-strings
  • Running ruff and ty over your code
  1. Read a JSON array of objects from stdin.
  2. Parse the JSON into typed dataclass instances.
  3. Print a formatted summary to stdout: total count, a per-language breakdown with average years of experience, and the most/least experienced developer.

Pipe this to stdin:

[
{"name": "Alice", "language": "Kotlin", "years": 3},
{"name": "Bob", "language": "TypeScript", "years": 5},
{"name": "Charlie", "language": "Go", "years": 2},
{"name": "Diana", "language": "Kotlin", "years": 1},
{"name": "Eve", "language": "TypeScript", "years": 4}
]
=== Developer Summary ===
Total developers: 5
By language:
Go: 1 developers (avg 2.0 years)
Kotlin: 2 developers (avg 2.0 years)
TypeScript: 2 developers (avg 4.5 years)
Most experienced: Bob (TypeScript, 5 years)
Least experienced: Diana (Kotlin, 1 years)

A uv project with a single module does the whole job:

  • Directoryjson-cli/
    • pyproject.toml deps + metadata
    • uv.lock lockfile
    • .python-version pinned interpreter
    • Directorysrc/
      • Directoryjsoncli/
        • __init__.py
        • main.py the entire program

Scaffold it:

Terminal window
uv init --package --app json-cli
cd json-cli
uv add --dev ruff ty # no runtime deps — stdlib only

No runtime dependencies — everything we need is in the standard library. We add a script entry point so the tool can be invoked by name, plus dev tooling.

pyproject.toml
[project]
name = "json-cli"
version = "0.1.0"
description = "Summarize a JSON array of developers from stdin"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [] # stdlib only
[project.scripts]
json-cli = "jsoncli.main:main" # creates a `json-cli` command
[dependency-groups]
dev = [
"ruff>=0.6",
"ty",
]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[build-system]
requires = ["uv_build>=0.7"]
build-backend = "uv_build"

The whole program. A few things to notice if you’re coming from TS/Go:

  • @dataclass turns a class into a plain data holder — auto-generated __init__, __repr__, and equality. It’s the Python interface/type (TS) or struct (Go) for plain data. json.loads hands us dicts; we splat each into the dataclass with **raw.
  • sys.stdin.read() reads all of stdin in one go — the idiomatic “slurp stdin,” like io.ReadAll(os.Stdin) in Go.
  • defaultdict(list) is “group by” without the key-existence dance, and statistics.mean is the average. min/max take a key= function, like slices.MaxFunc in Go or Math.max(...arr.map(...)) in TS but cleaner.
  • f-strings (f"{name}: {n} developers") are JS template literals; f"{avg:.1f}" formats to one decimal place.
src/jsoncli/main.py
import json
import sys
from collections import defaultdict
from dataclasses import dataclass
from statistics import mean
@dataclass
class Developer:
name: str
language: str
years: int
def main() -> None:
raw_input = sys.stdin.read()
if not raw_input.strip():
print("Error: No input provided. Pipe JSON to stdin.", file=sys.stderr)
sys.exit(1)
try:
records = json.loads(raw_input)
developers = [Developer(**rec) for rec in records]
except (json.JSONDecodeError, TypeError) as exc:
print(f"Error parsing JSON: {exc}", file=sys.stderr)
sys.exit(1)
if not developers:
print("No developers found in input.")
return
print("=== Developer Summary ===")
print(f"Total developers: {len(developers)}")
print()
# Group by language, then report the count and average years for each.
by_language: dict[str, list[Developer]] = defaultdict(list)
for dev in developers:
by_language[dev.language].append(dev)
print("By language:")
for language in sorted(by_language):
devs = by_language[language]
avg_years = mean(d.years for d in devs)
print(f" {language}: {len(devs)} developers (avg {avg_years:.1f} years)")
print()
# Find the extremes with a key function.
most = max(developers, key=lambda d: d.years)
least = min(developers, key=lambda d: d.years)
print(f"Most experienced: {most.name} ({most.language}, {most.years} years)")
print(f"Least experienced: {least.name} ({least.language}, {least.years} years)")
if __name__ == "__main__":
main()
  1. Lint, format, and type-check (the habit to build from day one):

    Terminal window
    uv run ruff format .
    uv run ruff check .
    uv run ty check
  2. Run with sample data piped to stdin:

    Terminal window
    echo '[{"name":"Alice","language":"Kotlin","years":3},{"name":"Bob","language":"TypeScript","years":5}]' \
    | uv run json-cli
  3. Or pipe a file (save the example input as sample.json):

    Terminal window
    uv run json-cli < sample.json
    # equivalently: cat sample.json | uv run python -m jsoncli.main