Skip to main content

How to Build a Lightweight Dev Environment from Scratch

Build a lightweight Python development environment with Git, uv, Ruff, and pytest, including a small project and reproducible verification.

i
it.you Editorial Team
November 13, 2026 7 min read
How this article was made: An AI-assisted draft was fact-checked, its cited sources were opened, its examples were reviewed, and the published version was edited under the responsibility of it.you Editorial Team. Read our editorial process.
Lightweight Python development environment using Git, uv, Ruff, pytest, and a small checked project

Build a lightweight dev environment with Git, one project-scoped runtime, a lockfile, and repeatable checks; then prove a clean rebuild produces the same result.

Choose the smallest stack that preserves reproducibility

A lightweight environment is not merely one with few tools. It should also isolate project dependencies, document its runtime requirement, and be rebuildable without remembering manual steps.

For the worked scenario below, the entire machine-level toolset is Git, a text editor, and uv. The project uses Python 3.12, the standard library for its application code, and two development dependencies: pytest and Ruff. This avoids a container, database, language-version manager, and global package installation.

uv creates a project-local .venv, resolves dependencies into uv.lock, and runs commands in that environment without shell activation. Its documentation says the lockfile records exact resolved versions and should be committed for consistent installations. See the official project guide.

Situation Appropriate choice
A small script or service with pure-Python dependencies Use Git, uv, pyproject.toml, and a local virtual environment.
The project needs system libraries, multiple services, or production-like Linux behavior Use a container or virtual machine so the operating-system layer is reproducible too.
Company policy requires an approved interpreter and package mirror Disable automatic runtime downloads and configure the approved system Python and registry.
You are changing only one isolated script with no third-party packages Python’s built-in venv may be enough; virtual environments are designed to be disposable and project-specific. Python documents the standard workflow here.

Worked example: build a JSON task checker

The scenario is a command-line utility that receives a local JSON task record, validates three required fields, trims the title, and emits deterministic JSON. The concrete input will be task ID 17 with the title Ship changelog.

1. Verify the foundation

Install Git through your operating system’s trusted package channel. Install uv using an option from its official installation page, preferably a managed package such as Homebrew or WinGet. If you use the standalone installer, inspect the downloaded script before executing it rather than blindly piping network content into a shell.

git --version
uv --version

Both commands should print a version and exit successfully. You do not need a global Python installation: uv can download a compatible Python when one is unavailable, although this can be disabled with --no-python-downloads. The Python-version documentation explains the download and selection rules.

2. Create an intentionally minimal project

mkdir api-snapshot-checker
cd api-snapshot-checker
git init
uv init --bare --python 3.12
uv add --dev "pytest>=8,<10" "ruff>=0.8,<1"

The --bare option creates only pyproject.toml; it does not add sample application files or initialize version control. Adding the development tools creates or updates uv.lock and prepares .venv. Development dependencies remain separate from application requirements, as described in the official dependency guide.

Create .gitignore with the following contents:

.venv/
.env
*.local.json
__pycache__/
.pytest_cache/
.ruff_cache/

Keep pyproject.toml and uv.lock in Git, but never commit .venv. Git’s documentation explains that repository-level ignore patterns belong in .gitignore and warns that ignore rules do not affect files already tracked. Review the authoritative Git rules.

3. Add the program and sample input

Create checker.py:

import json
import sys
from pathlib import Path

REQUIRED = {"id", "title", "completed"}


def normalize(path):
    data = json.loads(Path(path).read_text(encoding="utf-8"))

    if not isinstance(data, dict):
        raise ValueError("top level must be an object")

    missing = sorted(REQUIRED - data.keys())
    if missing:
        raise ValueError(f"missing keys: {', '.join(missing)}")

    if isinstance(data["id"], bool) or not isinstance(data["id"], int):
        raise ValueError("id must be an integer")
    if not isinstance(data["title"], str) or not data["title"].strip():
        raise ValueError("title must be a non-empty string")
    if type(data["completed"]) is not bool:
        raise ValueError("completed must be a boolean")

    return {
        "id": data["id"],
        "title": data["title"].strip(),
        "completed": data["completed"],
    }


def main():
    try:
        result = normalize(sys.argv[1])
    except IndexError:
        print("usage: checker.py INPUT.json", file=sys.stderr)
        return 2
    except (OSError, json.JSONDecodeError, ValueError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    print(json.dumps(result, sort_keys=True))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Create task.json with this realistic sample input:

{"title":"  Ship changelog  ","completed":false,"id":17}

If you are unsure whether hand-edited input is syntactically valid, check this synthetic example with the JSON Validator. Do not paste confidential API responses, credentials, customer records, or unreleased business data into any web-based tool.

4. Run it and compare the exact result

uv run --locked python checker.py task.json

The expected standard output is exactly:

{"completed": false, "id": 17, "title": "Ship changelog"}

The keys are alphabetized and the surrounding title spaces are removed. If a larger expected snapshot is difficult to inspect visually, compare two sanitized files with the JSON Diff Checker; for sensitive material, perform the comparison locally instead.

Add repeatable quality checks

Create test_checker.py:

import pytest

from checker import normalize


def test_normalizes_valid_task(tmp_path):
    path = tmp_path / "task.json"
    path.write_text(
        '{"title":" Ship changelog ","completed":false,"id":17}',
        encoding="utf-8",
    )

    assert normalize(path) == {
        "id": 17,
        "title": "Ship changelog",
        "completed": False,
    }


def test_rejects_missing_field(tmp_path):
    path = tmp_path / "task.json"
    path.write_text('{"id":17,"title":"Ship changelog"}', encoding="utf-8")

    with pytest.raises(ValueError, match="missing keys: completed"):
        normalize(path)

Run the formatter check, linter, and tests:

uv run --locked ruff format --check .
uv run --locked ruff check .
uv run --locked pytest -q

The expected test summary is 2 passed. Ruff should exit without reporting a formatting change or lint violation. The --locked boundary is important: it makes the command fail if project metadata and the lockfile disagree instead of silently changing dependency resolution. The locking and syncing reference defines this behavior.

Prove that the environment is rebuildable

A working environment may contain accidental packages or stale state. Test a clean reconstruction before treating the setup as complete.

  1. Run git status --short and confirm that the expected source, configuration, sample, and lock files are present.
  2. Rename .venv to .venv.backup. Renaming is safer than immediately deleting it because rollback remains possible.
  3. Run uv sync --locked to build a fresh .venv from committed metadata.
  4. Repeat the three quality-check commands and the sample execution.
  5. Remove .venv.backup only after the rebuilt environment produces the expected output.

Before renaming or removing anything, verify the directory with git rev-parse --show-toplevel. Never use a recursive deletion command against a variable, wildcard, home directory, or path you have not printed and checked.

Failure modes and decision boundaries

  • A dependency command changes the lock unexpectedly. Stop and inspect git diff -- pyproject.toml uv.lock. Regenerate the lock only when the dependency change is intentional; do not accept an unexplained upgrade merely to make a check pass.
  • uv sync --locked reports that the lockfile is outdated. Someone changed pyproject.toml without updating uv.lock. On the branch making that change, run uv lock, review the diff, and rerun the checks. On an unrelated branch, restore consistency rather than rewriting the lock.
  • The project works locally but fails on another operating system. A lockfile controls language packages, not every native library, compiler, or service. If dependencies compile extensions or require system packages, a container, development image, or documented OS package manifest is safer.
  • Initialization joins an unexpected parent workspace. Check parent directories for another pyproject.toml. Move the project or use uv init --no-workspace when independence is intended.
  • A proxy or restricted network blocks runtime downloads. Use an organization-approved Python installation and package index, then pass --no-python-downloads. Do not bypass TLS validation or use an untrusted mirror simply to finish setup.
  • An ignored secret still appears in Git. The file was probably tracked before the ignore rule existed. Remove it from the index, revoke or rotate the credential, and follow the repository owner’s history-cleanup procedure. Ignoring a file does not erase earlier commits.

Protect credentials and private data

Use synthetic fixtures such as task.json whenever real records are unnecessary. Store local configuration in an ignored file only as a convenience, not as a full secrets-management system. Environment variables may be exposed to processes, logs, or dumps, so higher-risk credentials belong in an approved secret store with limited access and rotation. OWASP’s secrets-management guidance describes these risks.

Before committing, run git diff --cached and inspect every added line. If a credential has entered Git, assume exposure: rotate it first, then coordinate history rewriting. Also review third-party packages before adding them; a lockfile preserves a selected artifact but does not prove that the dependency itself is trustworthy.

Reusable action checklist

  • Choose the minimum runtime, package manager, version-control system, and editor required.
  • Pin a supported runtime range and keep dependencies project-local.
  • Commit project metadata and the lockfile; ignore generated environments, caches, and local secrets.
  • Create one representative input with an exact expected result.
  • Add tests for the successful path and at least one invalid input.
  • Run formatting, linting, tests, and the example with locked dependencies.
  • Rebuild from an absent environment and repeat every verification.
  • Use a container or managed image when native libraries or service parity matter.
  • Review staged files for credentials and private data before every first commit.

Sources and further reading

Continue with the Developer Tools guide collection for related explanations reviewed under the same editorial standard.

Further reading

Choose a focused full-stack toolkit for source control, runtime management, testing, APIs, containers, databases, observability, and security.
Read the parser’s offset, preserve the original text, and inspect a small window around it; then convert the index using the parser’s own character-counting.