<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[RAMESH NEUPANE]]></title><description><![CDATA[I'm Ramesh, an AI/ML engineer in training, sharing hands-on notes on machine learning, deep learning, and system design as I build toward a career in AI enginee]]></description><link>https://akakritagya.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 11 Sep 2026 07:33:56 GMT</lastBuildDate><atom:link href="https://akakritagya.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How I Set Up a Python Project in 2026: uv, ruff, mypy and friends]]></title><description><![CDATA[Hello, World! 👋
Every Python project I start now begins with the same set of tools, and the setup ends with a repo that formats itself, lints itself, types itself and tests itself. This post is a sho]]></description><link>https://akakritagya.hashnode.dev/how-i-set-up-a-python-project-in-2026-uv-ruff-mypy-and-friends</link><guid isPermaLink="true">https://akakritagya.hashnode.dev/how-i-set-up-a-python-project-in-2026-uv-ruff-mypy-and-friends</guid><category><![CDATA[Python]]></category><category><![CDATA[UV ]]></category><category><![CDATA[ruff]]></category><category><![CDATA[pytest]]></category><category><![CDATA[Mypy]]></category><category><![CDATA[pre-commit]]></category><category><![CDATA[python-tooling]]></category><dc:creator><![CDATA[Ramesh Neupane]]></dc:creator><pubDate>Mon, 24 Aug 2026 12:00:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a5a0359b426f82c89405370/48fa7596-520f-4c31-803f-29cd7ac1e4ac.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Hello, World! 👋</p>
<p>Every Python project I start now begins with the same set of tools, and the setup ends with a repo that formats itself, lints itself, types itself and tests itself. This post is a shortcut: follow along to the end and your project will be set up as smoothly as mine, without iterating over messages in an AI chat to get there (and you save the tokens too ;).</p>
<p><code>uv</code> sits on top of the project and every command goes through that one tool.</p>
<hr />
<h2>Before <code>uv</code> and After <code>uv</code></h2>
<h4>1. Before <code>uv</code>: The stack we used to need</h4>
<p>Python packaging was never a one-tool thing. It was always a bunch of overlapping tools, each with its own config file and its own failure mode.</p>
<table>
<thead>
<tr>
<th>Job</th>
<th>Old tool</th>
</tr>
</thead>
<tbody><tr>
<td>Install packages</td>
<td><code>pip</code></td>
</tr>
<tr>
<td>Isolate environments</td>
<td><code>venv</code> / <code>virtualenv</code></td>
</tr>
<tr>
<td>Manage Python versions</td>
<td><code>pyenv</code></td>
</tr>
<tr>
<td>Lock dependencies</td>
<td><code>pip-tools</code> (<code>requirements.in</code> → <code>requirements.txt</code>)</td>
</tr>
<tr>
<td>Run CLI tools in isolation</td>
<td><code>pipx</code></td>
</tr>
<tr>
<td>Build &amp; publish</td>
<td><code>build</code> + <code>twine</code></td>
</tr>
</tbody></table>
<p>The hassle of calling multiple tools was already there. On top of that, you followed a long list of instructions instead of a few commands under one tool — and still ended up with two machines running different environments.</p>
<p>Then, <code>uv</code> comes to the rescue 😎.</p>
<h4>2. After <code>uv</code>: What <code>uv</code> has to offer</h4>
<p><code>uv</code> is one Rust binary from Astral that does all six jobs (and many more), and it offers peace of mind to developers 😆.</p>
<table>
<thead>
<tr>
<th>Job</th>
<th>uv command</th>
</tr>
</thead>
<tbody><tr>
<td>Create a project</td>
<td><code>uv init</code></td>
</tr>
<tr>
<td>Add a dependency</td>
<td><code>uv add requests</code></td>
</tr>
<tr>
<td>Install / reproduce env</td>
<td><code>uv sync</code></td>
</tr>
<tr>
<td>Lock</td>
<td><code>uv lock</code> (writes <code>uv.lock</code>)</td>
</tr>
<tr>
<td>Run something</td>
<td><code>uv run pytest</code></td>
</tr>
<tr>
<td>Manage Python itself</td>
<td><code>uv python install 3.14</code>, <code>uv python pin 3.14</code></td>
</tr>
<tr>
<td>Run a CLI tool, isolated</td>
<td><code>uvx ruff check</code></td>
</tr>
<tr>
<td>Build &amp; publish</td>
<td><code>uv build</code>, <code>uv publish</code></td>
</tr>
</tbody></table>
<p>There are a few other things worth noticing about it, and they're why I switched.</p>
<ol>
<li><p>Installing and resolving takes seconds, not minutes. Speed in project initialization always fascinates developers.</p>
</li>
<li><p>With <code>uv</code>, you don't need to activate a virtual environment. <code>uv run</code> syncs the environment and executes in it.</p>
</li>
<li><p><code>.python-version</code> pins the Python version and downloads it.</p>
</li>
<li><p>Last but not least, everything lives in <code>pyproject.toml</code>.</p>
</li>
</ol>
<p>And the caveats I want to consider about uv:</p>
<ol>
<li><p>It is still pre-1.0 (0.12.x at the time of writing), and it matures with every release. It's stable and widely used in production, although the version number might make you skeptical.</p>
</li>
<li><p>It does not replace <code>conda</code> for non-Python binaries such as CUDA toolkits, some scientific C/Fortran stacks, etc.</p>
</li>
</ol>
<p>Now, let's move on to the project setup.</p>
<p>Follow along.</p>
<hr />
<h2>The Fundamental Toolchain</h2>
<p>Five tools. Each one does exactly one job, and every one of them is configured in <code>pyproject.toml</code>.</p>
<h4>1. <a href="https://docs.astral.sh/uv/">uv</a></h4>
<p>The 2026 default. One tool for Python versions, environments, dependencies and lockfiles. 10–100× faster; a <code>uv.lock</code> makes your build reproducible.</p>
<ol>
<li><p><strong>Install</strong></p>
<pre><code class="language-shell">curl -LsSf https://astral.sh/uv/install.sh | sh      # macOS / Linux
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"   # Windows
</code></pre>
</li>
<li><p><strong>Verify</strong></p>
<pre><code class="language-shell">uv --version
uv self update
</code></pre>
<p>Worth doing once:</p>
<pre><code class="language-shell">uv generate-shell-completion zsh &gt;&gt; ~/.zshrc   # or bash / fish / powershell
</code></pre>
</li>
<li><p><strong>Get a Python</strong></p>
<p>Self-contained Python builds live in uv's own directory and won't touch system Python.</p>
<pre><code class="language-shell">uv python list              # what's available and what's installed
uv python install 3.14      # download a standalone build
uv python find              # which one would be used here
</code></pre>
</li>
<li><p><strong>Initialize a Project</strong></p>
<pre><code class="language-shell">uv init myapp  # use your project name instead of 'myapp'
# OR
uv init --app --package myapp

# this command creates a packaged app: src/ + entry point (default)
</code></pre>
<p>Project structure after <code>uv init</code>:</p>
<pre><code class="language-plaintext">myapp/
├── .git/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
└── src/myapp/
    └── __init__.py
</code></pre>
<p>Initialize library project:</p>
<pre><code class="language-shell">uv init --lib mylib  # use your library name instead of 'mylib'

# this command creates a library: src/ + py.typed, no entry point
</code></pre>
<p>Then the project structure will be:</p>
<pre><code class="language-plaintext">mylib/
├── .git/
├── .gitignore
├── .python-version              # e.g. 3.14
├── README.md
├── pyproject.toml
└── src/
    └── mylib/
        ├── __init__.py          # def hello() -&gt; str: ...
        └── py.typed             # empty marker file
</code></pre>
</li>
<li><p><strong>Explore</strong> <code>.python-version</code></p>
<pre><code class="language-plaintext">3.14
</code></pre>
</li>
<li><p><strong>Explore</strong> <code>pyproject.toml</code></p>
<pre><code class="language-toml">[project]
name = "myapp"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
    { name = "your-username", email = "your-email@example.com" }
]
requires-python = "&gt;=3.14"
dependencies = []

[project.scripts]
myapp = "myapp:main"

[build-system]
requires = ["uv_build&gt;=0.12.5,&lt;0.13.0"]
build-backend = "uv_build"
</code></pre>
</li>
</ol>
<p>After the project has been initialized, let's move on to linting and formatting.</p>
<hr />
<h4>2. <a href="https://docs.astral.sh/ruff/">ruff</a></h4>
<p>One more Rust binary from Astral, and it wears two hats: <strong>linter</strong> and <strong>formatter</strong>. This single tool retires <code>black</code>, <code>flake8</code> (and its dozen plugins), <code>isort</code>, <code>pyupgrade</code> and <code>pydocstyle</code> — one config block instead of five dotfiles.</p>
<table>
<thead>
<tr>
<th>Job</th>
<th>Old tool</th>
<th>With <code>ruff</code></th>
</tr>
</thead>
<tbody><tr>
<td>Format code</td>
<td><code>black</code></td>
<td><code>ruff format</code></td>
</tr>
<tr>
<td>Lint</td>
<td><code>flake8</code> + plugins</td>
<td><code>ruff check</code></td>
</tr>
<tr>
<td>Sort imports</td>
<td><code>isort</code></td>
<td><code>ruff check --fix</code> (<code>I001</code>, on by default)</td>
</tr>
<tr>
<td>Modernize syntax</td>
<td><code>pyupgrade</code></td>
<td><code>ruff check --fix</code> (<code>UP</code> rules)</td>
</tr>
<tr>
<td>Docstring style</td>
<td><code>pydocstyle</code></td>
<td><code>D</code> rules</td>
</tr>
</tbody></table>
<ol>
<li><p><strong>Add it</strong></p>
<pre><code class="language-shell">uv add --dev "ruff&gt;=0.16.4,&lt;0.17"   # lands in [dependency-groups] dev, explore pyproject.toml
uv run ruff --version
</code></pre>
</li>
<li><p><strong>Configure it</strong></p>
<p>Everything goes in <code>pyproject.toml</code>.</p>
<pre><code class="language-toml"># ruff
[tool.ruff]
extend-exclude = ["migrations", "notebooks"]
indent-width = 4
line-length = 80
required-version = "&gt;=0.16,&lt;0.17"
show-fixes = true
src = ["src", "tests"]

[tool.ruff.lint]
dummy-variable-rgx = "^_.*$" # underscore-prefixed names are intentionally unused
ignore = [
  "D203",
  "D213", # the two pydocstyle rules that fight each other
  "E741", # ambiguous names — comes back once you select all of "E"
] 
select = [
  "E",
  "F",
  "I",
  "UP",
  "B",
  "SIM",
  "RUF", 
  "D",
  "ANN",
  "S",
  "PTH", 
  "C901",
  "PLR0913", 
] 
unfixable = ["F401", "F841"] # flag unused imports/vars, don't silently delete them 

[tool.ruff.lint.isort]
combine-as-imports = true
known-first-party = ["myapp"]

[tool.ruff.lint.mccabe]
max-complexity = 10 # branches per function before ruff says "split this"

[tool.ruff.lint.pylint]
max-args = 7 # parameters per function; self/cls don't count

[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"] # re-exports aren't unused
"tests/**/*.py" = ["S101", "D", "ANN"] # asserts are fine in tests

[tool.ruff.lint.pydocstyle]
convention = "google" # or numpy

[tool.ruff.format]
docstring-code-format = true # formats code inside your docstrings too
docstring-code-line-length = "dynamic" 
indent-style = "space" 
line-ending = "lf" # stops CRLF noise in diffs on Windows
quote-style = "double" 
skip-magic-trailing-comma = false # keep false: your trailing comma forces the split
</code></pre>
</li>
<li><p><strong>Run it</strong></p>
<pre><code class="language-shell">uv run ruff check .           # lint
uv run ruff check . --fix     # apply the safe fixes
uv run ruff format .          # format
</code></pre>
<p>Order matters: <code>check --fix</code> → <code>format</code>. Fixes can leave lines that need re-wrapping.</p>
</li>
</ol>
<hr />
<h4>3. <a href="https://mypy.readthedocs.io/en/stable/">mypy</a></h4>
<p>Ruff checks that an annotation exists; mypy checks that it isn't lying. A function that promises <code>-&gt; User</code> but quietly returns <code>None</code> runs fine today and explodes at <code>user.email</code> three weeks from now — mypy catches it before you commit.</p>
<pre><code class="language-python">def get_user(user_id: int) -&gt; User:
    return db.query(user_id)   # actually returns User | None
</code></pre>
<p>Ruff is happy with this; mypy is not.</p>
<ol>
<li><p><strong>Add it</strong></p>
<pre><code class="language-shell">uv add --dev "mypy&gt;=2.3,&lt;3"
uv run mypy --version
</code></pre>
</li>
<li><p><strong>Configure it</strong></p>
<pre><code class="language-toml"># mypy
[tool.mypy]
python_version = "3.14"       # mypy does NOT read requires-python — set it here
files = ["src", "tests"]
strict = true                 # ~15 flags at once; start here on a new project
warn_unreachable = true       # not in strict, but catches real bugs
pretty = true

# per-module escape hatches — note the DOUBLE brackets (array of tables)
[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_defs = false

[[tool.mypy.overrides]]
module = ["some_untyped_lib.*"]
ignore_missing_imports = true   # scope this per-module, never globally
</code></pre>
</li>
<li><p><strong>Run it</strong></p>
<pre><code class="language-shell">uv run mypy              # uses `files` from the config
uv run mypy src/myapp    # or point it somewhere specific
</code></pre>
</li>
</ol>
<hr />
<h4>4. <a href="https://docs.pytest.org/en/stable/">pytest</a></h4>
<p><code>pytest</code> is the test <em>runner</em>. You write the tests; it finds them, executes them, and reports what broke.</p>
<ol>
<li><p><strong>Add it</strong></p>
<pre><code class="language-shell">uv add --dev "pytest&gt;=9.1,&lt;10" pytest-cov
uv run pytest --version
</code></pre>
</li>
<li><p><strong>Configure it</strong></p>
<pre><code class="language-toml"># pytest
[tool.pytest.ini_options]
testpaths = ["tests"]          # don't scan .venv, don't scan src
addopts = [
    "-ra",                     # summary of everything that wasn't a plain pass
    "--strict-markers",        # a typo'd marker is an error, not a silent skip
    "--strict-config",         # a typo'd config key here is an error too
    "--import-mode=importlib", # modern import mode; no __init__.py in tests/
]
xfail_strict = true            # an xfail that passes is a failure, not a shrug
filterwarnings = ["error"]     # deprecation warnings fail the suite
markers = [
    "slow: takes more than a second",
    "integration: touches the network or a real DB",
]

[tool.coverage.run]
source = ["src"]
branch = true          # was every if/else arm taken, not just every line

[tool.coverage.report]
exclude_also = [       # `also`, not `exclude_lines` — that one replaces the defaults
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
]
</code></pre>
</li>
<li><p><strong>Run it</strong></p>
<pre><code class="language-shell">uv run pytest                  # everything
uv run pytest -x               # stop at first failure
uv run pytest --lf             # only what failed last time
</code></pre>
</li>
</ol>
<hr />
<h4>5. <a href="https://pre-commit.com/">pre-commit</a></h4>
<p><code>pre-commit</code> wires the other four tools to <code>git commit</code>, so the repo enforces its own standards instead of trusting your discipline at 1am.</p>
<ol>
<li><p><strong>Add it</strong></p>
<pre><code class="language-shell">uv add --dev pre-commit
</code></pre>
</li>
<li><p><strong>Configure it</strong></p>
<p>Create <code>.pre-commit-config.yaml</code>, next to <code>pyproject.toml</code>:</p>
<pre><code class="language-yaml">fail_fast: true    # stop at the first failing hook

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-toml
      - id: check-yaml
      - id: check-merge-conflict
      - id: check-added-large-files

  - repo: https://github.com/astral-sh/uv-pre-commit
    rev: 0.12.5
    hooks:
      - id: uv-lock          # keeps uv.lock in sync with pyproject.toml

  - repo: https://github.com/compilerla/conventional-pre-commit
    rev: v4.4.0
    hooks:
      - id: conventional-pre-commit
        stages: [commit-msg]

  # local hooks run against the uv-locked environment, so uv.lock is the
  # single source of truth for tool versions — no second `rev:` pin to drift.
  - repo: local
    hooks:
      - id: ruff-check
        name: ruff check
        entry: uv run ruff check --fix
        language: system
        types_or: [python, pyi]

      - id: ruff-format
        name: ruff format
        entry: uv run ruff format
        language: system
        types_or: [python, pyi]

      - id: mypy
        name: mypy
        entry: uv run mypy
        language: system
        types: [python]
        pass_filenames: false   # mypy needs the whole package, not staged files

      # pre-push, not pre-commit: the full suite is worth blocking a push over,
      # but running it on every single commit is what gets --no-verify'd once
      # the suite grows.
      - id: pytest
        name: pytest
        entry: uv run pytest
        language: system
        pass_filenames: false
        stages: [pre-push]
</code></pre>
<p>Now install hooks:</p>
<pre><code class="language-shell">uv run pre-commit install --install-hooks \
  --hook-type pre-commit --hook-type pre-push --hook-type commit-msg
</code></pre>
</li>
<li><p><strong>Run it</strong></p>
<pre><code class="language-shell">uv run pre-commit run --all-files   # first time: fix the whole repo at once
uv run pre-commit run               # just the staged files (what the git hook does)
</code></pre>
</li>
</ol>
<hr />
<h4>Explore further</h4>
<p>Five tools is the setup. Here's what I'd read about next, and why — depending on what the project turns into.</p>
<ol>
<li><p><code>uv build</code> <strong>and</strong> <code>uv publish</code> — the two commands from the table at the top this post never used. A tag-triggered CI job turns a release into <code>git tag v0.1.0 &amp;&amp; git push --tags</code>. Read up on <em>trusted publishing</em>: no API token in your repo secrets.</p>
</li>
<li><p><code>pydantic-settings</code> — a runtime dependency, not dev tooling. Scattered <code>os.environ</code> calls become one typed, validated object that fails at startup instead of at 3am.</p>
</li>
<li><p><code>structlog</code> — <code>print()</code> doesn't survive production and <code>logging</code> is configured by ritual. Structured JSON logs your aggregator can query.</p>
</li>
</ol>
<hr />
<h2>Your Daily Cheatsheet</h2>
<p>The setup is one-time cost. There will be moments where you will need these commands.</p>
<ol>
<li><p><strong>Add or update package</strong></p>
<pre><code class="language-shell">uv add &lt;package-name&gt;    # runtime dependency
uv add --dev &lt;package-name&gt;    # dev tool/dependency
uv remove &lt;package-name&gt;    # uninstall/drop dependency
uv lock --upgrade-package &lt;package-name&gt;    # upgrade one package
uv lock --upgrade    # upgrade all
uv sync    # when you see change in lockfile
</code></pre>
</li>
<li><p><strong>After you write some code</strong><br />Always remember to run in order.</p>
<pre><code class="language-shell">uv run ruff check . --fix
uv run ruff format .
uv run mypy
uv run pytest
</code></pre>
</li>
<li><p><strong>Before you commit</strong></p>
<pre><code class="language-shell">uv run pre-commit run
</code></pre>
</li>
</ol>
<hr />
<h2>Conclusion</h2>
<p>Five tools, one config file, and a repo that formats, lints, types and tests itself without being asked. The setup takes twenty minutes once and then gets out of your way for the life of the project.</p>
<p>Start with <code>uv init</code>, add the rest as you need them — none of this has to land on day one. And if your <code>pyproject.toml</code> ends up looking nothing like mine, good. It's yours.</p>
<p>Good luck. 😃 👋.</p>
]]></content:encoded></item></channel></rss>