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-commithooks 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.