Skip to content

Repository files navigation

General Python Project Guidelines

choose the right python version

library

for a library, support all versions currently in the life cycle (see https://devguide.python.org/versions/)

app

for an app, use the highest python version that is compatible with all dependencies. The higher the python version, the better performance and longer support and security maintenance. If you need absolut stability, choose a version for which the bug fix phase is over, but the security maintenance phase is still active (see https://devguide.python.org/versions/).

dependency management

library

Use pyproject.toml and avoid requirements.txt for libraries, as it can lead to dependency conflicts for users. You don't want to use a lock file for a library since users will have their own dependency versions, and because a lock file is not compatible with dependency management tools.

Support the broadest range of versions for your dependencies, and use version specifiers (e.g., >=1.0,<2.0) to allow users to choose compatible versions. This way, users can install your library alongside other libraries without conflicts.

app

For apps, consider using pyproject.toml along with a dependency management tool like poetry (created by Sébastien Eustace and maintained by the poetry community), or uv (created by Astral which is owned by OpenAI, an independent company also behind ruff). These tools provide a lock file (a file containing the exact versions of all dependencies) that ensures reproducible builds and helps manage transitive dependencies.

pyproject.toml file

The pyproject.toml file is a standardized (allegedly) all-in-one configuration file for Python projects that can be used to specify build system requirements, project metadata, dependencies, and more. It is supported by most tools in the Python ecosystem, including build tools like setuptools and poetry, as well as dependency management tools.

It replaces the older setup.py and requirements.txt files, providing a more modern and flexible way to manage project configuration and dependencies.

folder structure

app (api with pydantic schemas and database)

my_api/
├── pyproject.toml
├── README.md
├── .env.example
├── alembic.ini
├── migrations/
│   ├── env.py
│   └── versions/
├── tests/
│   ├── unit/
│   ├── integration/
│   └── conftest.py
└── app/
	├── __init__.py
	├── main.py                # app entrypoint (FastAPI/Starlette)
	├── config.py              # settings (pydantic-settings)
	├── api/
	│   ├── __init__.py
	│   ├── deps.py            # request-scoped dependencies
	│   └── v1/
	│       ├── __init__.py
	│       ├── router.py
	│       └── endpoints/
	│           ├── __init__.py
	│           └── users.py
	├── schemas/               # pydantic request/response models
	│   ├── __init__.py
	│   └── user.py
	├── models/                # ORM models (SQLAlchemy, etc.)
	│   ├── __init__.py
	│   └── user.py
	├── db/
	│   ├── __init__.py
	│   ├── base.py            # declarative base
	│   ├── session.py         # engine + session factory
	│   └── repositories/
	│       ├── __init__.py
	│       └── user_repo.py
	├── services/              # business logic
	│   ├── __init__.py
	│   └── user_service.py
	└── core/
		├── __init__.py
		├── errors.py
		└── logging.py

library

my_library/
├── pyproject.toml
├── README.md
├── LICENSE
├── tests/
│   ├── test_public_api.py
│   └── conftest.py
├── docs/
│   ├── index.md
│   └── api.md
└── my_library/                # can also be under src/ folder to prevent accidental imports from project root during tests
	├── __init__.py
	├── py.typed                # PEP 561 marker: required for type checkers to trust this
	│                           # package's annotations when it's installed as a dependency.
	│                           # Needed to honestly claim the "Typing :: Typed" classifier.
	├── _version.py
	├── api.py                 # public entrypoints
	├── types.py
	├── exceptions.py
	└── utils.py

init.py for Package Exports

init.py files are used to mark directories as Python package directories. They can also be used to control what is exposed when the package is imported. By defining the __all__ variable in the __init__.py file, you can specify which modules, classes, or functions should be accessible when a user imports your package.

If a directory has no init.py, Python treats it as a namespace package. Namespace packages cannot execute initialization code or define a package-level API through init.py, so users need to import submodules explicitly. Namespace packages are useful for large projects with multiple sub-packages, since they allow for merging of sub-packages from different distributions. You can read more about it in the official doc.

# mypackage/__init__.py
"""mypackage - A sample Python package."""

__version__ = "1.0.0"

# Export main classes/functions at package level
from mypackage.models import User, Post
from mypackage.utils import format_name

__all__ = ["User", "Post", "format_name"]

Python quick guidelines

The quality of the code can be assessed by how much time it takes to a new developer to understand what your code is doing.

Use a linter to help you practice these guidelines. I recommend ruff that check the style and quality of your python code.

Quick guidelines

  • keep your functions short (less than 30 lines)
  • use docstring to document the modules, functions and classes
  • respect naming conventions (use the right case at the right place)
  • keep your lines short (less than 99 characters) by breaking them at the right place
  • use type hints to help the reader understand what is expected as input and output of your functions

Naming convention

It exists a few cases used throughout python:

  • the snake_case more info: in lowercase with words separated by underscores.
  • the PascalCase (or UpperCamelCase) more info: Start each word with a capital letter. Do not separate words with underscores.
  • the UPPERCASE for constants: everything in caps with words separated by underscores.
Type Naming Convention Examples
Function snake_case function, my_function
Variable snake_case x, var, my_variable
Class PascalCase Model, MyClass
Method snake_case class_method, method
Constant Use an uppercase single letter, word, or words. Separate words with underscores to improve readability. CONSTANT, MY_CONSTANT, MY_LONG_CONSTANT
Module snake_case, keep short module.py, my_module.py
Package lowercase, keep short, avoid underscores package, mypackage

Code layout

Blank lines

From https://peps.python.org/pep-0008/#blank-lines:

Surround top-level function and class definitions with two blank lines.

Method definitions inside a class are surrounded by a single blank line.

Extra blank lines may be used (sparingly) to separate groups of related functions. Blank lines may be omitted between a bunch of related one-liners (e.g. a set of dummy implementations).

Use blank lines in functions, sparingly, to indicate logical sections.

Examples

Details
def foo(): # top-level function
    something = 5


def bar(): # top-level function
    somethingelse = 42
class A: # class
    CONSTANT = 2

    def foo(self): # method
        """bla.
        
        Returns
        -------
        int:
            the bla.
        """
        bla = self.CONSTANT

        return bla

    def bar(self, height: int = 10): # method
        """Compute the height.
        
        Parameters
        ----------
        height : int
            the new height.
        """
        bla = height


class B: # class
    pass

Line length

for code lines, PEP8 recommands not to exceed 99 characters and advice to stick to 79 characters. Do not make your variable name cryptic in order to meet this guideline.

comments and docstrings must be wrapped at 72 characters.

Line breaking and indentation

Use 4 spaces indentation except for line continuation and in hanging indent.

Because it can be hard to keep a line under the limit of 99 characters, you can use line breaking. Python will assume line continuation when code is inside parenthesis.

# Aligned with opening delimiter.
foo = long_function_name(var_one, var_two,
                         var_three, var_four)

# Hanging indents should add a level.
foo = long_function_name(
    var_one, var_two,
    var_three, var_four)

# Add an extra level of indentation to distinguish arguments from the rest.
def long_function_name(
        var_one, var_two, var_three,
        var_four):
    print(var_one)

The closing brace/bracket/parenthesis on multiline constructs may be lined up under the first character of the line that starts the multiline construct.

my_list = [
    1, 2, 3,
    4, 5, 6,
]
result = some_function_that_takes_arguments(
    'a', 'b', 'c',
    'd', 'e', 'f',
)

You can also break the line before using an operator.

income = (gross_wages
          + taxable_interest
          + (dividends - qualified_dividends)
          - ira_deduction
          - student_loan_interest)

Comments

Use inline comments sparingly. Comments should answer the question "why?" since the "what?" question can be answered by reading your code (which is hoopefully clear enough).

Docstrings

Python has no convention for docstrings, but numpy docstring convention exists.

Example:

def between(unit: str, start: int, end: int) -> Callable:
    """
    Constraint trigger that matches when a datetime field is within a range.

    `Between` is a **constraint**: it filters time, but does not define a cadence.

    Parameters
    ----------
    unit : str
        Which datetime component to examine.
    start : int
        Inclusive lower bound for the selected unit.
    end : int
        Inclusive upper bound for the selected unit.

	Returns
	-------
	Callable
		A function that takes a datetime and returns True if it is within the range.

	Raises
	------
	ValueError
		If `start > end`.

    Notes
    -----
    Inclusivity
        Ranges are **inclusive**: a datetime matches when ``start <= value <= end``.

    Examples
    --------
    Working hours: any 10 minute between 09:00 and 17:00

    >>> from schedium import Between, Every
    >>> trigger = Every(unit="minute", interval=10) & Between(unit="hour_of_day", start=9, end=17)

    First business week of the month (Mon..Fri)

    >>> from schedium import Between, Tick
    >>> trigger = (
    ...     Tick(granularity="day")
    ...     & Between(unit="day_of_month", start=1, end=7)
    ...     & Between(unit="day_of_week", start=1, end=5)
    ... )
    """
	...

Imports order

We can distinguish 3 types of python import:

  • the standard library. It is including by default and does not need to be installed via pip. You can find pathlib, os, random, multiprocessing and many more.
  • the external packages like numpy, pandas or matplotlib. They have to be installed via pip
  • the internal packages or modules. They are references to your own code.

You should include them in the presented order i.e., the modules from the standard library, the external packages, the internal packages.

About

Template and guidelines for any python project

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors