Itt jársz most: Kezdőlap > Alkalmazásfejlesztés > Python library publikálása PyPI-ra

Szűrő megjelenítése

Python library publikálása PyPI-ra

Kifejezetten meglepett, mikor úgy nagyjából egy éve úgy döntöttem, a Domino-hoz készült CLI toolt megosztanám PyPI-n, aztán kiderült, hogy ez egyáltalán nem annyira triviális, mint gondoltam volna. Őszinte leszek, a Python és főleg a mögötte levő ökoszisztéma a legkevésbé kedvenc technológiám, de bizonyos esetekben túl kényelmes és egyszerű ahhoz, hogy pusztán csak ignoráljam - minden bizonnyal nem csak én vagyok ezzel így. CLI tool-ok, szerver-üzemeltetésre és -automatizálásra scriptek, kisebb serverless alkalmazások és hát persze az AI, valamint big data tool-ok elengedhetetlen nyelve és szerintem ezzel nincs is baj, mivel egy könnyen tanulható, kompakt nyelvről van szó. A csomagkezelője viszont mintha megragadt volna az előző évezredben, amit szerencsére a Python fejlesztői közösség is lát, így a csomagolásra több lehetőségünk is van third-party packagerek formájában, sajnos azonban pont ez okozza a zavart az erőben.

Szóval, rákeresve a csomagolás mikéntjére, viszonylag könnyű belefutni korábbi StackOverFlow bejegyzésekbe vagy random fejlesztői blogok cikkeibe, amik a setup.py használatát taglalják. Tény, hogy még mindig része az SDK-nak, azonban egy friss verzió már jelzi, hogy deprekált lett az API, ideje új megoldást keresni. Természetesen ignorálhatjuk a figyelmeztetést akár, valószínűleg még sokáig része lesz az SDK-nek a setup.py, azonban személyes javaslatom az, hogy amennyiben egy új feature bevezetése előtt állunk, soha ne építsük azt deprekált API-kra. Szépen és jól hangzik, hogy "majd tech debt-ben kijavítjuk, ha fontossá válik", a valóság azonban akkor vág arcul, mikor már blockert okoz a deprekált API használata és még mindig arra épül a megoldás. Mert annál hamarabb úgysem lesz idő foglalkozni vele.

Szóval némi további Google keresés elvezetett a PyPI hivatalos oldalára, ami készségesen elém tárta a helyes, de "jó" tutorial anyagokhoz mérten hiányos megoldást. Az alábbiakban elsősorban ezeket a hiányosságokat szeretném kicsit bemutatni, persze kezdve az alapoktól.

Projekt struktúra

Mivel a legtöbb esetben már előre fix, a nyelv és a hozzá tartozó build tool által definiált mappaszerkezettel dolgozunk (például Maven és NPM), így nem meglepő módon a Python packageket is meghatározott struktúrába kell szerveznünk. Egy eredetileg nem terjesztésre szánt Python csomag mappastruktúráját azonban nem szükséges korlátok közé szorítanunk, így adódhatott, hogy a teljes repository-t át kellett emiatt szerveznem. Vegyük az alábbi struktúrát:

my_project (ezt vegyük a repository root-nak)
├─ main.py
└─ core
   ├─ __init__.py
   └─ some_module.py

Tegyük fel, hogy a some_module.py exportál egy hello() nevű függvényt, a main.py pedig importálja azt a from core.some_module import hello utasítással. Lokális környezetben, a my_project mappában állva a main.py lefuttatható, meg fogja találni a hello függvényt. (IDEA alatt egyébként ez okozott egy kis zűrzavart a Domino CLI esetében, de ugyanezt a hibát már nem tapasztaltam ezzel a kis példaprojekttel: a korábban telepített csomag kódját látta, nem a friss, módosított fájlokat. A javításához a futtatási módot script-ről module-ra állítottam, a belépési pontot pedig domino_cli.__main__-re. Ha hasonlót tapasztalnánk, ez a trükk segíthet.) A kis projektünk jelenleg azonban nem volna képes futni csomagolás után, ennek pedig nagyon egyszerű oka van: nincs telepítve core nevű package. De hát miért is lenne, a core egy mappa a projektben, benne egy __init__.py file-lal, tehát egy szabványos Python-module, nem? Nos, igen és nem. Valóban ott van a core module, valóban része a projektnek, de becsomagolva nem a nyers fájlrendszert, nem a my_project mappa tartalmát látja majd a Python runtime, hanem egy main.py fájlt, ami hivatkozik egy core.some_module nevű package-re.

Ahhoz, hogy a csomagolt projekt is működjön, szükségünk lesz némi átalakításra:

my_project (ez továbbra is a repository root)
├─ main.py (ez itt alapvetően nem szükséges, visszatérünk rá)
└─ my_cli_tool (ez pedig a package root lesz!)
   ├─ __init__.py
   └─ core
      ├─ __init__.py
      ├─ some_module.py
      └─ service
         ├─ __init__.py
         └─ some_service.py

Tehát a repository gyökerében létrehoztunk egy másik mappát, ami a my_cli_tool nevet viseli (természetesen ez akármi lehet, viszont nagyon fontos, hogy ez lesz később a package neve, tehát mindenképp egyedi nevet válasszunk, nehogy összeakadjon egy másik, hasonló nevű csomaggal, ha mindkettő importálva van egy projektben). Ezt a mappát is megjelöljük module-nak (az __init__.py fájllal), majd áthúzunk ide mindent. Ha továbbra is szeretnénk önállóan tudni futtatni a csomagot (például egy CLI toolt írunk), szükségünk lesz egy belépési pontra, amit fejlesztés közben tudunk használni (kivéve, ha minden teszteléshez újra akarjuk csomagolni a projektet), ez lesz a main.py szerepe, az nem lesz a csomag része! A korábban említett import viszont meg fog így változni, és vele együtt az összes többi is: from my_cli_tool.core.some_module import hello. De ha például a hello függvény hivatkozik a some_service.py hello_service() függvényére, az import ott is abszolút útvonalas lesz: from my_cli_tool.core.service.some_service import hello_service (megjegyzés: a csomagon belül relatív importokat is használhatunk, ez esetben tehát a core packageben levő hello_service importot kicserélhetjük arra, hogy from .service.some_service import hello_service).

A package descriptor

A jó hír, hogy tulajdonképpen a nehezén túl vagyunk. A következő lépés a package descriptor beállítása, aminek hasonló a szerepe, mint a Maven pom.xml vagy az npm package.json fájlának -- ebben az esetben egy pyproject.toml nevű fájlra lesz szükségünk. A toml egy egyszerű leíró nyelv, benne kulcs-érték párokat tárolhatunk, illetve úgynevezett "táblázatokat" definiálhatunk, a pyproject fájlokban ennél többre nem is lesz szükségünk. A descriptor legfontosabb része a build-system table beállítása, ami meghatározza, milyen packaging toollal történjen a csomagolás. Most a Hatchet fogjuk használni (ezzel találkoztam eddig leginkább).

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

A következő szekció a project table paramétereit tartalmazza. Mint a neve is sugallja, itt adjuk meg a projektünk alapadatait. Például a projekt nevét, leírását, verzióját, minimum Python verzióját, függéseit (gyakorlatilag a requirements.txt fájl formátumában kell őket felvennünk listába) és további egyéb paramétereit (a cikk végén linkelem az ezzel kapcsolatos hivatalos dokumentációt).

[project]
name = "my-cli-tool"
version = "1.0.0"
description = "This is my first published Python project"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "bcrypt==4.0.0",
    "pyyaml",
    "requests",
]
classifiers = [
    "Development Status :: 5 - Production/Stable",
    "Intended Audience :: Developers"
]

A classifier-öket szeretném külön kiemelni mielőtt továbbmegyünk, mivel ezek szerepe talán nem annyira egyértelmű. Mivel a csomagot a PyPI-re fogjuk kitelepíteni, érdemes megadnunk ezeket a gyakorlatilag speciális címkéket, amik segítenek a PyPI-nak kategorizálni a libraryt. Természetesen ez a packagek keresésében is sokat segít, a megfelelően kategorizált librarykre, így a mi sajátunkra is könnyebben akadnak majd rá a felhasználók. Lent linkelem a jelenleg támogatott classifier-ök listáját is.

A következő fontos szegmens a build target beállítása, ehhez már konkrétan Hatch paramétereket fogunk használni. A Hatch két artifactet fog előállítani, egy tar.gz fájlt, amit az sdist csomagoló állít elő, és egy .whl fájlt, ami a wheel csomagoló eredménye. Utóbbi a dokumentációja szerint automatikusan keresi meg a csomagolandó fájlokat, a Domino CLI esetében például nem is kellett állítanom rajta semmit. Előbbi viszont a tar.gz fájlba becsomagol mindent, amit az aktuális mappában talál, így például a teszteket, illetve bármi egyebet is. Emiatt az sdist csomagolónak szüksége volt az alábbi paraméterre:

[tool.hatch.build.targets.sdist]
include = [
    "/my_cli_tool",
]

Mindkét csomagot meg lehet nyitni bármilyen archive managerrel (pl. WinRAR, 7zip, tar), mindenképp tudom javasolni a csomagok ellenőrzését az első kitelepítés előtt, és ha bármi félrecsúszna, a Hatch dokumentációja meglehetősen részletes segítséget tud nyújtani.

Amennyiben a library valójában egy CLI tool, érdemes lehet azt beállítani futtatható packageként. Ehhez csupán ki kell egészítenünk a pyproject.toml fájlt egy újabb table szegmenssel, aminek egyetlen kulcsa lesz, ez lesz a futtatható állomány neve, és a library oldali belépési pont az értéke, module:függvénynév formában.

[project.scripts]
my-cli-tool = "my_cli_tool:main"

A fenti példa tehát a my_cli_tool moduleban található main függvényhez köti a my-cli-tool parancsot. A library telepítése után a terminálban a my-cli-tool parancs begépelésével tudjuk majd azt elindítani. Hangsúlyozom, ez csak akkor szükséges, ha egy CLI toolt fejlesztettünk, egy egyszerű libet csak függésként fogunk majd behúzni, ahogy tennénk azt bármelyik másikkal. A hivatkozott main() függvénynek pedig léteznie kell a my_cli_tool mappában található __init__.py fájlban, és természetesen a CLI tool indítását kell végezze.

Még egy érdekességet hadd említsek meg, ez pedig a "dinamikus" verzió használata. Megtehetjük azt is, hogy nem a pyproject.toml fájlban állítjuk be a csomag verzióját, hanem egy .py fájlban. Egy __version__ vagy VERSION néven felvett konstanst a Hatch meg fog találni a beállított fájlban, és annak értékét használja majd verziószámnak. Ehhez egy kisebb módosítás szükséges a pyproject.toml-ben: a version kulcsot távolítsuk el és adjuk hozzá az alábbi sorokat:

[project]
# ...
dynamic = ["version"]
# ...

[tool.hatch.version]
# feltételezve, hogy a __version__ konstans a gyökér package __init__.py fájlában található
path = "my_cli_tool/__init__.py"
# ...

PyPI fiók regisztrálás

Ezen a ponton lényegében készen vagyunk a konfigurációval, de még nincs hova feltöltenünk a csomagot, szóval itt az ideje regisztrálni egy PyPI fiókot. A Python Package Index (PyPI) egy ingyenesen használható package index, akár csak az npm Registry vagy a Maven Central. Végezzük el a regisztrációt és jelentkezzünk be. Érdemes kitölteni a profilunkat, MFA-t beállítani, szóval nagyjából a "szokásos lépéseket" elvégezni, illetve van egy nagyon fontos lépés, ez pedig egy API token igénylése, enélkül nem fogunk tudni feltölteni. Ehhez a jobb felső sarokban levő menü alatt navigáljunk az Account Settings > API tokens felületre, majd kattintsunk az "Add API token" gombra. Mivel még nincs projektünk, a Scope lenyílóban válasszuk az Entire account opciót (később ezt érdemes lehet megváltoztatni a projektre). A Permission minden esetben csak csomagfeltöltésre fog vonatkozni, a név pedig tetszőleges. A generált tokent mentsünk el, nem fogja többet megjeleníteni a felület, de később szükségünk lesz rá!

Amennyiben bizonytalanok vagyunk az első feltöltés sikerességében, érdemes az egész folyamatot újrakezdeni a Test PyPI indexen. Ez a PyPI egyfajta staging környezete, amolyan "piszkos index", kísérletezni, tesztelgetni kiváló. Csomagokat innen is tudunk telepíteni egyébként, később megmutatom hogyan. Egyébként pedig teljesen ugyanúgy működik, mint az éles PyPI, szóval minden nüansznyi furcsaságát kitapasztalhatjuk anélkül, hogy például egy verziószámot elvesztegetnénk.

Ugyanis a PyPI-nak van néhány szigorú szabálya:

  • Egy már feltöltött verziót nem lehet törölni! Ha egyszer valami felkerült, az fent marad.
  • A fent levő verziót felülírni sem lehet egy ugyanolyan verziójú, de más tartalmú csomaggal.
  • Az egyetlen lehetőség a "yank-elés", ami ugyan nem távolítja a csomagot az indexből, de amennyiben nincs rá direkt verzióhivatkozás, többé nem ajánlja a pip kliensnek. Ismétlem, amennyiben nincs rá direkt verzióhivatkozás! Tehát, ha az 1.0.0 verziót yankeltük, de valaki hivatkozza a csomagot my-cli-tool==1.0.0 verzióval, ő továbbra is meg fogja kapni, míg a többiek számára az 1.0.0-t már nem fogja feloldani, ha van újabb verzió.

A csomag buildelése és feltöltése

Már csak a csomagolás és az indexbe feltöltés van hátra. Előbbihez a fölöttébb kreatív nevű build nevű module-ra lesz szükségünk, majd el kell azt indítanunk module-ként:

# OS-től és environmenttől függően lehet a py3 bináris kell a python helyett, 
# vagy hívhatjuk közvetlenül a pip-et is
python -m pip install build
python -m build

A parancs eredményeképp létrejönnek a már korábban említett .tar.gz és .whl archivumok, alapértelmezetten a ./dist mappában. Próbaképp telepíthetjük is a .whl csomagot, méghozzá újfent a pip segítségével:

python -m pip install ./dist/my_cli_tool-1.0.0-py3-none-any.whl

Egyébként egy requirements.txt fájlban is hivatkozhatjuk a .whl csomagot, annak teljes elérési útvonalával (Windowson figyeljünk a \ jel / jelre váltására). Attól függően, hogy a csomagunk egy CLI eszköz vagy egy egyszerű library, vagy a my-cli-tool paranccsal, vagy a library-nek az alkalmazásban történű importálásával tudjuk azt használni:

from my_cli_tool import hello

def call_hello():
    hello()

A PyPI-ba való telepítéshez a twine nevű module-ra lesz szükségünk. Ahhoz azonban, hogy a feltöltés sikeres legyen, a Twine-nak szüksége lesz a korábban elkészített API kulcsra. Talán feltűnhetett, hogy felhasználó nevet nem kaptunk, csak a kulcsot magát. Ennek oka, hogy a felhasználónév mindig __token__ lesz, függetlenül attól, hogyan konfiguráljuk a Twine-t. Ugyanis két módon tudjuk beállítani a kulcsot, lokális fejlesztői környezetben történő teszteléshez a legegyszerűbb, ha a következőket tesszük:

  1. A felhasználói gyökérkönyvtár alatt (Windows-on %userprofile% a fájlkezelőben, vagy cd %userprofile% egy terminálon, Linuxon cd ~) hozzunk létre egy .pypirc fáljt.
  2. Írjuk bele a következőket és mentsünk a fájlt (ha a Test PyPI indexet akarjuk csak használni, az éles index beállításához az első sort cseréljük [pypi]-ra):
    [testpypi]
      username = __token__
      password = <ide jön a korábban előállított API kulcs>
    

Jöhet a Twine és a pkginfo module telepítése és a saját csomagunk feltöltése (a pkginfo hivatalosan nem kell, de Linuxon problémát tapasztaltam a buildeléssel enélkül):

python -m pip install twine pkginfo

# ha az éles PyPI-ra akarjuk a csomagot feltölteni, 
# csak el kell hagyni a --repository kapcsolót
python -m twine upload --repository testpypi dist/*

Gratulálok, sikeresen feltöltötted az első Python csomagodat a (Test) PyPI-ra! A már feltöltött csomagokat a https://pypi.org/manage/projects/ (éles index) illetve a https://test.pypi.org/manage/projects/ (teszt index) linkeken lehet megtekinteni és kezelni. Telepíteni azokat a szokásos módon, a pip kliens segítségével lehet, a fenti példa esetében:

python -m pip install my-cli-tool

# vagy a teszt indexből
python -m pip install --index-url https://test.pypi.org/simple/ my-cli-tool

Illetve, a requirements.txt fájlba felvéve is működni fog a telepítés, mint bármely másik csomaggal.

CI/CD integrálás

Amennyiben CI/CD eszközzel akarjuk végezni a csomagolást (automatizálva), nyilván szükségünk lesz egy scriptre, ami a fenti lépéseket automatikusan elvégzi. A választott CI/CD eszköztől függően az alábbi lépések eltérhetnek, de mellékelek egy scriptet, ami többé-kevésbé működőképes kell legyen. Természetesen szükségünk lesz egy előre telepített Python 3.x környezetre, vagy egy Python build imagere (CircleCI-on például a cimg/python base image tökéletes választás).

# telepítsük a build, twine és pkginfo module-okat
# ezutóbbi lehetséges, hogy nem kell, nekem viszont enélkül nem működött a buildelés
python -m pip install build twine pkginfo [további packagek ...]

# alternatívaképp, ha a függések össze vannak gyűjtve egy requirements.txt fájlban:
python -m pip install -r requirements.txt

# ha vannak tesztjeink, itt az ideje lefuttatni őket
# az alábbi parancs tesztlefedettség analízist is futtat
python -m coverage run -m unittest discover

# jöhet a buidelés
python -m build

# végül a feltöltés éles indexre...
python -m twine upload -u $PYPI_USERNAME -p $PYPI_PASSWORD -r pypi --skip-existing ./dist/*

# ... vagy teszt indexre
python -m twine upload -u $TEST_PYPI_USERNAME -p $TEST_PYPI_PASSWORD -r testpypi --skip-existing ./dist/*

A Twine parancsban most láthatunk még 1-2 kapcsolót a korábbiakhoz képest, ezek jelentése a következő:

  • -u / --username: felhasználónév, ez továbbra is a már korábban említett __token__ lesz, minden esetben;
  • -p / --password: jelszó, vagyis a korábban kiállított API kulcs (az API kulcsot SOHA NE COMMITOLJUK a repositoryba, mert azzal feltöltési hozzáférést adunk bárkinek, aki hozzáfér a repositoryhoz!);
  • Az előző két kapcsoló amiatt szükséges, mert CI/CD környezetben nem érdemes a .pypirc fájl létrehozásával babrálni, egyszerűbb környezeti változókból átadni az értékeket.
  • -r / --repository: cél index, az értéke pypi az éles, illetve testpypi a teszt indexhez;
  • --skip-existing: korábban említettem, hogy egy már feltöltött verziót nem lehet felülírni. Ha véletlenül megismételnénk a feltöltést ugyanazzal a verzióval, a pipeline el fog bukni. Ez a kapcsoló utasítja a Twine-t, hogy csak figyelmeztessen minket a hibára, de ne bukjon el a parancs. Igazából, ha már biztosak vagyunk abban, hogy a pipeline tökéletesen működik, ezt a kapcsolót nyugodtan hagyjuk el, addig is, míg kísérletezünk a beállításával, érdemes megtartani, hogy véletlenül ne pazaroljunk el verziókat.
  • dist/*: ez pedig természetesen a .tar.gz és a .whl csomagok elérési útvonala, ezeket fogja feltölteni az indexbe.

Ezzel készen is vagyunk. Bevallom, ezt a cikket valamivel rövidebbre terveztem, sajnos nem a legtriviálisabb a Python csomagok létrehozása. De nem is vészes és ha arra volna szükségetek, remélem a cikkem némiképp segít majd boldogulni vele.

PyProject.toml fájlok struktúrája
Hatch PyProject build system
PyPI classifierök
Működő példa - Domino CLI

Kommentek

Komment írásához jelentkezz be
Bejelentkezés

Még senki nem szólt hozzá ehhez a bejegyzéshez.