Setup mínimo en python

Crear un fichero .py y ejecutar un script de Python es muy sencillo. Sin embargo, configurar un mínimo entorno de desarrollo que te ayude a programar con ciertas garantías, puede ser algo “intimidante” si estás empezando.

Este post incluirá un par de recetas para tener listo en un periquete un nuevo proyecto de Python y algunos consejos iniciales para ponerlo en marcha.

Serán un par porque todavía ando divagando sobre si usar pipenv o poetry. Aunque, para ser honesto, a día de hoy me gusta más poetry y por eso ha recibo más cariño y actualizaciones en este post.

Dependencias

DependenciaUso
isortOrdenación de import
blackFormatear código
flake8Linter, también podrías usar pylint
pflake8Configuración de flake8 en pyproject.toml en vez del fichero .flake8
pytestFramework de testing
assertpyLibrería de aserciones
pytest-asyncioTests asíncronos (async, await)
pytest-covCobertura de código
mypyTipado opcional
pre-commitGit hook
.gitignoreAuto-generado

Esta lista es la que es, pero podría ser otra perfectamente. Es decir, está totalmente sesgada por lo que yo considero “más o menos” indispensable en cualquier proyecto de Python. Siéntete libre de añadir y/o eliminar todo aquello que consideres oportuno. De hecho, sólo añadiendo plugins de flake8 podría crecer considerablemente.

El script es PowerShell, pero entiendo debería ser sencillo adaptarlo a tu shell preferida.

Ambos scripts definen un par de variables $app (nombre del paquete) y $directory (directorio donde crear el proyecto). $directory puede ser lo que tu quieras, no es relevante. $app sin embargo será el nombre del paquete de Python, luego tendrías que usar (si es un nombre compuesto) la notación snake_case.

$app = "my_project"
$directory = "MyProject"

poetry

$app = "app1"
$directory = "App1"
poetry new --name $app --src $directory
cd $directory
# If you don't want to use default python version, you should edit pyproject.toml 
# and change the python version. e.g. python = "~3.10" in [tool.poetry.dependencies]. 
# After that, look for the path to the desired python version with py --list and run this command:
#   poetry env use <path_to_desired_python_version>
poetry add -G dev isort black flake8 flake8-pyproject pytest assertpy pytest-asyncio pytest-cov mypy pre-commit
echo "[tool.black]
line-length = 120
target-version = [""py311""]  # Adjust this to your needs, https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#t-target-version

[tool.isort]
profile = ""black""

[tool.flake8]
max-line-length = 120
max-doc-length = 120
extend-ignore = [
    ""E203"",  # https://www.flake8rules.com/rules/E203.html
    ""CCE002""  # https://github.com/best-doctor/flake8-class-attributes-order?tab=readme-ov-file#error-codes
]

[tool.mypy]
disallow_untyped_defs = true
no_implicit_reexport = true
warn_redundant_casts = true
warn_unused_ignores = true
show_error_codes = true
pretty = true
show_error_context = true
show_column_numbers = true

[[tool.mypy.overrides]]
module = [
    ""assertpy.*""
]
ignore_missing_imports = true

[tool.pytest.ini_options]
pythonpath = ""src/""
asyncio_mode = ""auto""

[tool.coverage.run]
omit = [""tests/*""]

[tool.coverage.report]
exclude_also = [
    ""@abstractmethod"", # https://github.com/pytest-dev/pytest-cov/issues/428#issuecomment-884982610
    ""@abc.abstractmethod"",
    ""if TYPE_CHECKING:""
]" >> pyproject.toml

echo "fail_fast: true
repos:
-   repo: https://github.com/python-poetry/poetry
    rev: 1.2.2
    hooks:
      - id: poetry-check
        verbose: true
#      - id: poetry-lock
#        verbose: true
-   repo: https://github.com/PyCQA/isort
    rev: 5.10.1
    hooks:
      - id: isort
        verbose: true
-   repo: https://github.com/psf/black
    rev: 22.10.0
    hooks:
      - id: black
        verbose: true
-   repo: local
    hooks:
    -   id: Flake8-pyproject
        name: Flake8-pyproject
        entry: flake8p
        language: system
        types: [python]
        verbose: true
-   repo: local
    hooks:
    -   id: mypy
        name: mypy
        entry: mypy
        language: system
        pass_filenames: false
        args: ["".""]
        verbose: true
        always_run: true" > .pre-commit-config.yaml
Invoke-RestMethod -Method Get -Uri "https://www.toptal.com/developers/gitignore/api/python,pycharm,visualstudiocode" | Out-File .gitignore
git init
poetry run pre-commit install
poetry run pre-commit autoupdate
echo "def greet(name: str) -> str:
    return f""Hello {name}""


if __name__ == ""__main__"":
    print(greet(""World""))" > src\$app\main.py
echo "from assertpy import assert_that

from $app.main import greet


def test_greet() -> None:
    assert_that(greet(""World"")).is_equal_to(""Hello World"")" > tests\test_main.py

El hook de poetry lock es muy opinable. Además de llevarse un tiempo precioso, dejo a tu elección el actualizar librerías de forma tan alegre.

│   .gitignore
│   .pre-commit-config.yaml
│   poetry.lock
│   pyproject.toml
│   README.md
├───src
│   └───app1
│           main.py
│           __init__.py
└───tests
        test_main.py
        __init__.py

pipenv

A pipenv no le quiero dejar de lado porque con él empezó mi andadura en Python y le tengo cariño. Sin embargo, si empezara hoy a programar en Python (y como te decía al principio), te recomendaría optar por poetry.

$app = "app1"
$directory = "App1"
mkdir $directory
cd $directory
pipenv install --dev isort black flake8 flake8-pyproject pytest assertpy pytest-asyncio pytest-cov mypy pre-commit
echo "[tool.black]`
line-length = 120
target-version = [""py311""]

[tool.isort]
profile = ""black""

[tool.flake8]
max-line-length = 120
max-doc-length = 120
extend-ignore = [
    ""E203"",  # https://www.flake8rules.com/rules/E203.html
    ""CCE002""  # https://github.com/best-doctor/flake8-class-attributes-order?tab=readme-ov-file#error-codes
]

[tool.mypy]
disallow_untyped_defs = true
no_implicit_reexport = true
warn_redundant_casts = true
warn_unused_ignores = true
show_error_codes = true
pretty = true
show_error_context = true
show_column_numbers = true

[[tool.mypy.overrides]]
module = [
    ""assertpy.*""
]
ignore_missing_imports = true

[tool.pytest.ini_options]
pythonpath = ""src/""
asyncio_mode = ""auto""

[tool.coverage.run]
omit = [""tests/*""]

[tool.coverage.report]
exclude_also = [
    ""@abstractmethod"", # https://github.com/pytest-dev/pytest-cov/issues/428#issuecomment-884982610
    ""@abc.abstractmethod"",
    ""if TYPE_CHECKING:""
]" > pyproject.toml

echo "fail_fast: true`
repos:
-   repo: https://github.com/PyCQA/isort
    rev: 5.10.1
    hooks:
      - id: isort
        verbose: true
-   repo: https://github.com/psf/black
    rev: 22.10.0
    hooks:
      - id: black
        verbose: true
-   repo: local
    hooks:
    -   id: Flake8-pyproject
        name: Flake8-pyproject
        entry: flake8p
        language: system
        types: [python]
        verbose: true
-   repo: local
    hooks:
    -   id: mypy
        name: mypy
        entry: mypy
        language: system
        pass_filenames: false
        args: ["".""]
        verbose: true
        always_run: true" > .pre-commit-config.yaml
Invoke-RestMethod -Method Get -Uri "https://www.toptal.com/developers/gitignore/api/python,pycharm,visualstudiocode" | Out-File .gitignore
git init
pipenv run pre-commit install
pipenv run pre-commit autoupdate
mkdir src
mkdir src\$app
mkdir tests
echo "def greet(name: str) -> str:
    return f""Hello {name}""


if __name__ == ""__main__"":
    print(greet(""World""))" > src\$app\main.py
echo "from assertpy import assert_that

from $app.main import greet


def test_greet() -> None:
    assert_that(greet(""World"")).is_equal_to(""Hello World"")" > tests\test_main.py

Ahora aparecen los ficheros Pipfile y Pipfile.lock y desaparece poetry.lock.

│   .gitignore
│   .pre-commit-config.yaml
│   Pipfile
│   Pipfile.lock
│   pyproject.toml
├───src
│   └───app1
│           main.py
└───tests
        test_main.py

Entorno virtual

En este punto, nuestra siguiente acción debería ser activar el entorno virtual.

Ten en cuenta que, cuando borras la carpeta del proyecto, no se borrará el entorno virtual asociado (a no ser que esté dentro de la misma carpeta, que no es lo habitual aunque podría ser). Tanto pipenv como poetry tienen comandos para borrar el entorno virtual. pipenv -rm y poetry env remove. Sin embargo, si eres como yo y no te acuerdas nunca de hacerlo antes de borrar el proyecto, te tocará de vez de en cuando hacer limpia manualmente. Los de pipenv se guardan por defecto en %USERPROFILE%\.virtualenvs y los de poetry donde diga poetry config virtualenvs.path. Adicionalmente, pre-commit creará un entorno virtual para cada hook que no sea del tipo repo: local en %USERPROFILE%\.cache\pre-commit.

Para activar el entorno virtual desde línea de comandos, bien usa poetry shell o pipenv shell.

Después, lo siguiente debería funcionar:

python .\src\<your_app>\main.py
pytest
pytest --cov --cov-report=html

VSCode

En VSCode recuerda seleccionar el entorno virtual.

Para ejecutar los tests te recomiendo la extensión Test Explorer UI.

PyCharm

Para PyCharm, igualmente tendrás que seleccionar el interprete.

Además, para que los import de tests no te muestren ningún error en el IDE, tienes que marcar (si no lo está, porque últimamente parece lo está por defecto) la carpeta src como Sources Root (que hará que se agregue a la variable de entorno PYTHONPATH).

Para ejecutar los tests quizás tengas que decirle que tu framework es pytest (File > Settings > Tools > Python Integrared Tools > Testing > Default test runner).

python.exe

Si tuvieras que localizar manualmente el interprete (esto es el fichero python.exe de tu entorno virtual porque el IDE de turno no te lo detecta automáticamente) con pipenv --py o poetry env info puedes obtener la ruta.

Spark

¿Y si estoy usando pyspark? Pues este fichero tests\conftest.py me acompaña siempre:

import datetime
from distutils.dir_util import copy_tree
from pathlib import Path
from typing import Any

import pytest
from _pytest.config import Config
from _pytest.fixtures import FixtureRequest
from assertpy import add_extension
from assertpy.assertpy import AssertionBuilder
from dotenv import load_dotenv
from pyspark.sql import SparkSession


@pytest.fixture()
def test_name(request: FixtureRequest) -> str:
    return request.node.name


@pytest.fixture()
def test_data_dir(request: FixtureRequest, tmp_path: Path) -> Path:
    """Copy `tests/data` directory into `tmp_path`"""
    path = Path(request.path.parent)
    if path.name != "tests":
        while path.name != "tests":
            path = path.parent
    path /= "data"
    copy_tree(str(path), str(tmp_path))
    return tmp_path


@pytest.fixture()
def test_case_dir(request: FixtureRequest, tmp_path: Path) -> Path:
    """Copy `<file.py name without extension>` directory into `tmp_path`"""
    path = Path(request.path.parent) / request.path.stem
    copy_tree(str(path), str(tmp_path))
    return tmp_path


@pytest.fixture()
def test_method_dir(request: FixtureRequest, tmp_path: Path) -> Path:
    """Copy `<file.py name without extension>/<test name>` directory into `tmp_path`"""
    path = Path(request.path.parent) / request.path.stem / request.node.name
    copy_tree(str(path), str(tmp_path))
    return tmp_path


@pytest.fixture(scope="session")
def spark(request: FixtureRequest) -> SparkSession:
    # https://github.com/malexer/pytest-spark/issues/9#issue-434176947

    spark_ = (
        SparkSession.builder.master("local[*]")
        .config("spark.sql.shuffle.partitions", 1)
        .config("spark.default.parallelism", 1)
        .config("spark.rdd.compress", False)
        .config("spark.shuffle.compress", False)
        .config("spark.ui.showConsoleProgress", False)
        # .config("spark.ui.port", "8080")
        # .config("spark.port.maxRetries", "30")
        .getOrCreate()
    )
    # https://stackoverflow.com/questions/40608412/how-can-set-the-default-spark-logging-level
    # spark_.sparkContext.setLogLevel("info")

    # https://stackoverflow.com/questions/44058122/what-happens-if-sparksession-is-not-closed
    request.addfinalizer(lambda: spark_.sparkContext.stop())
    return spark_


def pytest_configure(config: Config) -> None:
    # https://docs.pytest.org/en/7.1.x/example/simple.html#detect-if-running-from-within-a-pytest-run
    # https://docs.pytest.org/en/latest/reference/reference.html#pytest.hookspec.pytest_configure
    load_dotenv(dotenv_path=(Path(__file__).parent / ".env"))

Y un test típico para probar que todo funciona correctamente sería algo así:

from pathlib import Path

from assertpy import assert_that
from pyspark.sql import SparkSession
from pyspark.sql.types import IntegerType, StringType, StructField, StructType


def test_spark(spark: SparkSession, tmp_path: Path) -> None:
    print(spark.version)
    df = spark.createDataFrame(
        [
            (1, "John"),
            (2, "Doe"),
        ],
        StructType([StructField("Id", IntegerType()), StructField("Name", StringType())]),
    )
    path = str(tmp_path / "test_spark")
    print(path)
    df.write.parquet(path)

Por cierto, el fichero tests\.env simplemente tendría esto. Si quieres saber más al respecto, puedes leer Instalar Spark en Windows

PYSPARK_PYTHON=python

FastAPI

Una pequeña variación de poetry usando FastAPI:

Si ya habías activado el entorno virtual, vuelve a declarar la variable $app.

poetry add fastapi[all]
echo "import uvicorn
from fastapi import FastAPI

app = FastAPI()


@app.get(""/"")
async def root():
    return ""Hello World!""


if __name__ == ""__main__"":
    uvicorn.run(""main:app"", reload=True)" > src\$app\main.py
echo "from http import HTTPStatus

from starlette.testclient import TestClient

from $app.main import app

client = TestClient(app)


def test_root():
    r = client.get(""/"")
    assert r.status_code == HTTPStatus.OK
    assert r.json() == ""Hello World!""" > tests\test_main.py

También puedes instalar algunos plugins para ayudarte un poco más.

El plugin de pydantic ya estará instalado porque el propio pydantic es una dependencia de fastapi.

Para instalar el plugin de SQLAlchemy, poetry add sqlalchemy[mypy] que instala el paquete sqlalchemy2-stubs como dependencia extra de sqlalchemy.

A continuación, añade lo siguiente en la sección [tool.mypy] de pyproject.toml:

plugins = [
    "sqlalchemy.ext.mypy.plugin",
    "pydantic.mypy",
]

pre-commit

Esta sección es una explicación de porqué usar repo: local en pre-commit.

Si no la leyeses no te culparía.

Es decir, el motivo de esto:

-   repo: local
    hooks:
    -   id: Flake8-pyproject
        name: Flake8-pyproject
        entry: flake8p
        language: system
        types: [python]
        verbose: true
-   repo: local
    hooks:
    -   id: mypy
        name: mypy
        entry: mypy
        language: system
        pass_filenames: false
        args: ["."]
        verbose: true
        always_run: true"

En vez de esto otro:

-   repo: https://github.com/john-hen/Flake8-pyproject
    rev: 1.2.0
    hooks:
    -   id: Flake8-pyproject
        verbose: true
-   repo: https://github.com/pre-commit/mirrors-mypy
    rev: v0.991
    hooks:
    -   id: mypy
        args: []
        verbose: true

mypy

Si usáramos la segunda opción, mypy . funcionaría pero pre-commit no:

pyproject.toml:1:1: error: Error importing plugin "sqlalchemy.ext.mypy.plugin":
No module named 'sqlalchemy'  [misc]
    [tool.poetry]
    ^
Found 1 error in 1 file (errors prevented further checking)

Eso es porque el directorio virtual que crea pre-commit para ejecutar el hook de mypy sólo instala mypy, no instala ninguna otra dependencia de nuestro proyecto, y como los plugins están en los paquetes sqlalchemy2-stubs y pydantic, no están disponibles en el entorno virtual de pre-commit. Para instalar los paquetes tendríamos que ser nosotros mismos quien se lo dijera a pre-commit en el hook de mypy:

additional_dependencies: ['sqlalchemy[mypy]==<your_current_version>',pydantic==<your_current_version>]

<your_current_version> se puede saber con poetry show <package>.

Ahora el error de pre-commit sería otro:

src\web_app1\main.py:1:1: error: Cannot find implementation or library stub
for module named "uvicorn"  [import]
    import uvicorn
    ^
src\web_app1\main.py:1:1: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
src\web_app1\main.py:2:1: error: Cannot find implementation or library stub
for module named "fastapi"  [import]
    from fastapi import FastAPI
    ^
tests\test_main.py:4:1: error: Cannot find implementation or library stub for
module named "starlette.testclient"  [import]
    from starlette.testclient import TestClient
    ^
Found 3 errors in 2 files (checked 4 source files)

Lo que estaría pasando es que mypy no sería capaz de seguir los import. Por defecto, pre-commit ejecuta mypy con --ignore-missing-imports (una mala decisión en mi opinión), pero al haber incluido args: [] en la definición del hook habríamos invalidado ese comportamiento predeterminado. Luego ahora tocaría también instalar en el entorno virtual de pre-commit las dependencias para las que quisiéramos seguir los import.

additional_dependencies: [sqlalchemy-stubs==0.4,pydantic==1.10.2,'fastapi[all]==0.87.0']

Los import que se hubieran ignorado deliberadamente en pyproject.toml no habría que incluirlos en additional_dependencies. En nuestro caso por ejemplo, ignoramos assertpy porque la librería no tiene tipos.

flake8

Al igual que con mypy, si usarámos plugins adicionales para flake8 habría que especificarlos en additional_dependencies

Para ver cuál es el problema se podría instalar un plugin cualquiera poetry add -G dev flake8-builtins.

Y con un código como el siguiente.

def function_using_a_parameter_that_shadows_built_in_name_list(list):
    print(list)

Al ejecutar flake8p recibirías un error pero no al usar pre-commit run Flake8-pyproject.

.\src\web_app1\main.py:12:64: A002 argument "list" is shadowing a python builtin

Para solucionarlo, bastaría con añadir la dependencia al hook de Flake8-pyproject:

additional_dependencies: [flake8-builtins==2.0.1]

repo: local

La verdad es que conseguir que mypy ./pre-commit run mypy y flake8p/pre-commit run Flake8-pyproject funcionen exactamente igual implica tener sincronizadas algunas dependencias vía additional_dependencies… y eso es un poco molesto. Sinceramente, no lo veo mantenible a largo plazo.

Ya no es sólo la perdida de confianza y que termines ejecutando en local mypy/flake8p además de pre-commit, es que puede ser que rompas la build en el servidor de integración por algo que “supuestamente” pre-commit te había dicho estaba ok.

Por eso y para no tener la mosca detrás de la oreja y olvidarnos de additional_dependencies, mejor usar repo: local y a correr.

Un saludo!


Ver también