From Script to Project

Many Python projects start as a single script.

That is normal.

A single script is often the best way to test an idea quickly.

However, if the code becomes useful, reused, tested, or shared with other people, it may be time to turn it into a small project.

This page shows the thinking process behind that transition.

Starting point: one script

A beginner project may start like this:

main.py

Example:

def normalize_whitespace(text: str) -> str:
    return " ".join(text.split())


def count_words(text: str) -> int:
    normalized_text = normalize_whitespace(text)

    if not normalized_text:
        return 0

    return len(normalized_text.split(" "))


text = "Python    project\nworkflow"
print(count_words(text))

This is fine for a quick experiment.

The code is small, readable, and easy to run:

python main.py

When a script starts to hurt

A single script becomes harder to maintain when:

  • it contains many unrelated functions,
  • it mixes logic with printing, plotting, or user input,
  • it becomes hard to test,
  • it is copied into other projects,
  • it needs dependencies,
  • another person needs to run it,
  • changes start breaking existing behavior.

At that point, the problem is not Python.

The problem is project organization.

First improvement: separate reusable logic

The reusable logic should not be mixed with script execution.

Instead of keeping everything in main.py, create a small module:

text_stats.py

Example:

def normalize_whitespace(text: str) -> str:
    return " ".join(text.split())


def count_words(text: str) -> int:
    normalized_text = normalize_whitespace(text)

    if not normalized_text:
        return 0

    return len(normalized_text.split(" "))

Then main.py can use it:

from text_stats import count_words

text = "Python    project\nworkflow"
print(count_words(text))

This is already better because reusable logic is separated from script execution.

However, the project is still flat:

main.py
text_stats.py

That may be enough for a tiny project, but it does not scale well.

Second improvement: create a package

A package groups related Python modules.

In this guide, the package lives inside src/:

src/
└── text_toolkit/
    ├── __init__.py
    └── text_stats.py

The reusable code moves to:

src/text_toolkit/text_stats.py

Example:

def normalize_whitespace(text: str) -> str:
    return " ".join(text.split())


def count_words(text: str) -> int:
    normalized_text = normalize_whitespace(text)

    if not normalized_text:
        return 0

    return len(normalized_text.split(" "))

The package can expose selected functions in:

src/text_toolkit/__init__.py

Example:

from text_toolkit.text_stats import count_words, normalize_whitespace

__all__ = [
    "count_words",
    "normalize_whitespace",
]

Now other code can import from the package:

from text_toolkit import count_words

Why use src/?

The src/ layout separates source code from the rest of the repository.

This makes the structure clearer:

src/      source code
tests/    tests
docs/     documentation
.github/  GitHub-specific configuration

It also avoids some import confusion that can happen when code is imported directly from the repository root.

In this guide, src/ is used to teach clean project structure.

Installing the package, building distributions, and publishing are separate steps.

Third improvement: add tests

Once logic is separated, it becomes easier to test.

Create:

tests/test_text_stats.py

Example:

from text_toolkit import count_words, normalize_whitespace


def test_normalize_whitespace_replaces_repeated_spaces() -> None:
    text = "Python    is   fun"

    result = normalize_whitespace(text)

    assert result == "Python is fun"


def test_count_words_handles_repeated_whitespace() -> None:
    text = "Python    project\nworkflow"

    result = count_words(text)

    assert result == 3

Tests make behavior explicit.

They also help protect the project when the code changes later.

Fourth improvement: describe the project

A project should describe its Python version, dependencies, and tool configuration.

This guide uses:

pyproject.toml

Example:

[project]
name = "text-toolkit"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []

[build-system]
requires = ["uv_build>=0.12.19,<0.13"]
build-backend = "uv_build"

[dependency-groups]
dev = [
    "mkdocs>=1.6.1",
    "mypy>=2.3.1",
    "pre-commit>=4.6.2",
    "pytest>=9.0.3",
    "pytest-cov>=7.1.0",
    "ruff>=0.15.12",
    "rumdl>=0.2.77",
]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.mypy]
python_version = "3.12"
files = ["src", "tests"]
strict = true

[tool.coverage.run]
branch = true
source = ["text_toolkit"]

[tool.coverage.report]
show_missing = true

[tool.rumdl]
disable = ["MD013"]

[tool.rumdl.per-file-ignores]
".github/pull_request_template.md" = ["MD041"]
"docs/markdown_review.md" = ["MD014"]

[tool.ruff]
line-length = 88
target-version = "py312"
src = ["src", "tests"]

[tool.ruff.lint]
select = [
    "E",
    "F",
    "I",
    "B",
    "UP",
]

The exact dependency versions may be different.

Keep the versions generated by uv.

The fast commit hooks and documentation site use the separate .pre-commit-config.yaml and mkdocs.yml files.

The build system lets uv sync install the project into its environment in editable mode. Tests can then import text_toolkit without a pytest-specific path setting.

Fifth improvement: lock dependencies

A project should be reproducible.

This guide uses:

uv.lock

The lockfile records exact dependency versions.

Commit it to Git.

This helps make local development and CI use the same resolved dependencies.

Sixth improvement: add local checks

A maintainable project should be easy to check.

In this guide, the main local checks are:

uv run ruff check .
uv run ruff format --check .
uv run rumdl check .
uv run mkdocs build --strict
uv run pytest --cov=text_toolkit --cov-report=term-missing
uv run mypy
uv build --no-sources

These commands check:

  • linting,
  • formatting,
  • behavior and test coverage,
  • type annotations,
  • package distribution builds.

If formatting fails, run:

uv run ruff format .

Then repeat the checks.

Seventh improvement: add CI

Local checks are useful, but people can forget to run them.

CI runs important checks automatically on GitHub.

This guide uses:

.github/workflows/ci.yml

A minimal CI workflow runs:

uv python install
uv sync --locked --group dev
uv run ruff check .
uv run ruff format --check .
uv run rumdl check .
uv run mkdocs build --strict
uv run pytest --cov=text_toolkit --cov-report=term-missing
uv run mypy
uv build --no-sources

This gives every pull request a basic safety net.

Eighth improvement: use branches and pull requests

A clean project is not only about files.

It is also about workflow.

Instead of committing directly to main, use:

branch → commit → pull request → CI → review → merge

Example:

git switch main
git pull
git switch -c test/add-text-stats-tests

# edit files

git status
git add tests/test_text_stats.py pyproject.toml uv.lock
git commit -m "test: add text statistics tests"
git push -u origin test/add-text-stats-tests

Then open a pull request on GitHub.

Before and after

A script may start like this:

main.py

A small project may grow into this:

modern-python-project-guide/
├── .github/
│   ├── workflows/
│   │   └── ci.yml
│   └── pull_request_template.md
├── docs/
│   ├── index.md
│   └── ...
├── src/
│   └── text_toolkit/
│       ├── __init__.py
│       └── text_stats.py
├── tests/
│   └── test_text_stats.py
├── .gitignore
├── .python-version
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── mkdocs.yml
├── pyproject.toml
└── uv.lock

This structure is not bigger for the sake of being bigger.

Each part has a role.

What changed?

The project now has:

  • source code in src/,
  • tests in tests/,
  • documentation in docs/,
  • dependencies described in pyproject.toml,
  • exact dependency versions in uv.lock,
  • local checks with Ruff, rumdl, MkDocs, pytest-cov, and mypy,
  • a locally previewable documentation site,
  • fast pre-commit hooks before commits,
  • CI with GitHub Actions,
  • a branch and pull request workflow.

This makes the project easier to run, test, review, and extend.

What did not change?

The core idea stayed simple.

The example code still does basic text processing.

The project structure changed because the code became something worth maintaining.

Good project structure does not need a complicated domain.

It needs a reason to exist.

Do not jump too far

A common mistake is moving from:

main.py

directly to a huge template with Docker, type checking, coverage, pre-commit hooks, packaging, release automation, and documentation sites.

Those tools can be useful later.

They are not required for the first clean project foundation.

Start with:

  • clear structure,
  • dependency management,
  • tests,
  • formatting,
  • linting,
  • CI,
  • pull requests.

Then add more tools only when they solve a real problem.

Rule of thumb

A script is good for testing an idea.

A project is better when the code should be reused, tested, reviewed, or shared.

Do not make the structure complicated too early.

But do not wait until the script becomes painful to organize it.