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
Dependencia | Uso |
---|---|
isort | Ordenación de import |
black | Formatear código |
flake8 | Linter, también podrías usar pylint |
pflake8 | Configuración de flake8 en pyproject.toml en vez del fichero .flake8 |
pytest | Framework de testing |
assertpy | Librería de aserciones |
pytest-asyncio | Tests asíncronos (async , await ) |
pytest-cov | Cobertura de código |
mypy | Tipado opcional |
pre-commit | Git hook |
.gitignore | Auto-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
comopoetry
tienen comandos para borrar el entorno virtual.pipenv -rm
ypoetry 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 depipenv
se guardan por defecto en%USERPROFILE%\.virtualenvs
y los depoetry
donde digapoetry config virtualenvs.path
. Adicionalmente,pre-commit
creará un entorno virtual para cada hook que no sea del tiporepo: 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.
- Plugin para PyCharm/Pydantic
- Plugin para Mypy/Pydantic
- Plugin para Mypy/SQLAlchemy
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 enpyproject.toml
no habría que incluirlos enadditional_dependencies
. En nuestro caso por ejemplo, ignoramosassertpy
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!