Välimuisti, pilvifunktiot, PostgreSQL ja avoin data – Pääteohjaus 6

Tässä ohjauksessa haetaan tietoa ulkoisista rajapinnoista requests-kirjastolla ja tallennetaan vastaukset palvelimen välimuistiin HTTP-otsakkeiden ohjeiden mukaan. Lisäksi julkaistaan pilvifunktio, otetaan käyttöön PostgreSQL-tietokanta Cloud SQL -palvelussa ja jäsennetään XML-dataa ElementTreellä.

Ohjaus perustuu luentoihin XML ja ulkopuoliset datalähteet palvelimella, REST-rajapinnat: HTTP-välimuisti ja Python ja tietokannat: PostgreSQL, yhteyspooli ja Cloud SQL. Sovelluksen omien laskentojen välimuisti (Flask-Caching) käsiteltiin ohjauksessa 4.

Pura ohjaus6-malli.zip-paketti ja asenna tarvittavat kirjastot virtuaaliympäristöön:

cd ohjaus6-malli
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt

Ulkoiset rajapinnat ja pakolliset otsakkeet

Kun palvelin hakee tietoa toisesta palvelusta, se on vieras, jonka on noudatettava talon sääntöjä. Useimmat julkiset rajapinnat edellyttävät, että pyynnössä kerrotaan, mikä sovellus tietoa hakee, ja rajoittavat pyyntöjen määrää. Kts. luennon luvut Datan hakeminen ja Välimuisti ja kohteliaisuus.

Tavallisimmat pyyntöjen otsakkeet
OtsakeTarkoitus
User-AgentSovelluksen nimi ja yhteystieto, esim. Resepti/1.0 (ylläpito@example.fi). Monet palvelut hylkäävät pyynnön, jos otsake puuttuu tai se on kirjaston oletusarvo, kuten python-requests/2.32.3.
AcceptMissä muodossa vastaus halutaan, esim. application/json.
AuthorizationAPI-avain tai token. Luetaan ympäristömuuttujasta tai Secret Managerista, ei koskaan koodista.
palvelukohtaisetesim. GitHubin X-GitHub-Api-Version kiinnittää rajapinnan version.

Harjoitellaan ensin rajapinta/lahde.py-harjoitusrajapinnalla, joka toimii omalla koneella ja käyttäytyy kuten moni julkinen rajapinta: se hylkää pyynnöt ilman omaa User-Agent-otsaketta, kertoo, kuinka kauan vastausta saa käyttää ja sallii 60 pyyntöä tunnissa. Harjoitusrajapinnan kanssa ei tarvitse pelätä, että oma IP-osoite joutuu estolistalle.

cd rajapinta
flask --app lahde run --port 5001

Kokeile ensin selaimella tai curlilla, mitä rajapinta vastaa:

curl -i http://127.0.0.1:5001/havainto
curl -i -A "Oma-sovellus/1.0 (oma@example.fi)" http://127.0.0.1:5001/havainto

Sama requests-kirjastolla (rajapinta/kokeile.py):

import time

import requests

URL = "http://127.0.0.1:5001/havainto"
TUNNISTE = {"User-Agent": "TIES4080-ohjaus6 (opiskelija@example.fi)"}

# 1. Ilman omaa User-Agent-otsaketta requests lähettää "python-requests/x.y.z".
vastaus = requests.get(URL, timeout=10)
print("1.", vastaus.status_code, vastaus.request.headers["User-Agent"], vastaus.json())

# 2. Sovelluksen nimi ja yhteystieto otsakkeessa.
vastaus = requests.get(URL, headers=TUNNISTE, timeout=10)
print("2.", vastaus.status_code, vastaus.json())
for nimi in ("Cache-Control", "Expires", "Last-Modified", "ETag", "X-RateLimit-Remaining"):
    print(f"   {nimi}: {vastaus.headers.get(nimi)}")

# 3. Ehdollinen pyyntö: onko tieto muuttunut sen jälkeen?
ehdot = dict(TUNNISTE, **{"If-Modified-Since": vastaus.headers["Last-Modified"]})
toinen = requests.get(URL, headers=ehdot, timeout=10)
print("3.", toinen.status_code, f"runko {len(toinen.content)} tavua")

# 4. Sama ETagilla. Jos tieto ehti muuttua, saadaan 200 ja uusi data.
ehdot = dict(TUNNISTE, **{"If-None-Match": vastaus.headers["ETag"]})
kolmas = requests.get(URL, headers=ehdot, timeout=10)
print("4.", kolmas.status_code, "X-RateLimit-Remaining:", kolmas.headers["X-RateLimit-Remaining"])

# 5. Aikakatkaisu: palvelin, joka ei vastaa, ei saa jumittaa ohjelmaa.
try:
    requests.get("http://10.255.255.1/", timeout=2)   # osoite, joka ei vastaa
except requests.Timeout:
    print("5. Aikakatkaisu 2 sekunnin jälkeen")
except requests.RequestException as virhe:
    print("5. Muu verkkovirhe:", type(virhe).__name__)
else:
    print("5. Jokin vastasi (esim. välityspalvelin)")

Tuloste on esimerkiksi:

1. 403 python-requests/2.32.3 {'virhe': 'Kerro User-Agent-otsakkeessa sovelluksesi nimi ja yhteystieto'}
2. 200 {'lampotila': -2.5, 'mitattu': '2026-09-29T12:50:00+00:00'}
   Cache-Control: max-age=30
   Expires: Tue, 29 Sep 2026 12:51:28 GMT
   Last-Modified: Tue, 29 Sep 2026 12:50:00 GMT
   ETag: "1e4ee57fad565b65"
   X-RateLimit-Remaining: 59
3. 304 runko 0 tavua
4. 304 X-RateLimit-Remaining: 57
5. Aikakatkaisu 2 sekunnin jälkeen

Palvelimen välimuisti

Jos sovellus hakee ulkoisen tiedon jokaisella sivupyynnöllä, se on hidas, kuluttaa ulkoisen palvelun kiintiötä ja lakkaa toimimasta, kun ulkoinen palvelu on alhaalla. Siksi vastaukset tallennetaan palvelimen välimuistiin. Tallennusaikaa ei tarvitse arvata, koska hyvin tehty rajapinta kertoo sen itse. Kts. luento HTTP-välimuisti.

Kuinka kauan vastausta saa käyttää: Expires ja Cache-Control

Vastauksen otsakkeet, jotka ohjaavat välimuistia
OtsakeEsimerkkiMerkitys
Cache-Control: max-agemax-age=30Vastausta saa käyttää 30 sekuntia. Menee Expires-otsakkeen edelle.
Cache-Control: no-storeVastausta ei saa tallentaa lainkaan.
ExpiresTue, 29 Sep 2026 12:51:28 GMTVanhempi tapa: kellonaika, jonka jälkeen vastaus on vanhentunut. Kelvoton arvo, kuten 0, tarkoittaa jo vanhentunutta.
Last-ModifiedTue, 29 Sep 2026 12:50:00 GMTMilloin tieto on viimeksi muuttunut.
ETag"1e4ee57fad565b65"Tiedon version tunniste. Muuttuu aina, kun tieto muuttuu.

Päivämäärät ovat HTTP-päiväyksiä ja aina GMT-ajassa. Pythonissa ne jäsennetään email.utils.parsedate_to_datetime-funktiolla ja muodostetaan email.utils.format_datetime(aika, usegmt=True)-funktiolla.

Onko tieto muuttunut: If-Modified-Since ja If-None-Match

Kun tallennettu vastaus vanhenee, sitä ei tarvitse heittää pois. Välimuisti kysyy palvelulta ehdollisella pyynnöllä, onko tieto muuttunut:

Jos tieto ei ole muuttunut, palvelu vastaa 304 Not Modified ilman runkoa, ja välimuisti jatkaa vanhan tiedon käyttöä uudella voimassaoloajalla. Muuten palvelu vastaa tavallisesti 200 OK ja uudella datalla. ETag on tarkempi, koska Last-Modified-aika on sekunnin tarkkuudella. Jos molemmat lähetetään, palvelin käyttää ETagia.

Välimuistin toteutus

rajapinta/valimuisti.py toteuttaa edellä kuvatun välimuistin sanakirjana palvelimen muistissa:

import threading
import time
from email.utils import parsedate_to_datetime

import requests

OTSAKKEET = {
    # Monet palvelut vaativat sovelluksen nimen ja yhteystiedon.
    "User-Agent": "TIES4080-ohjaus6 (opiskelija@example.fi)",
    "Accept": "application/json",
}
AIKAKATKAISU = 10               # sekuntia; ilman tätä jumittunut palvelu jumittaa myös sovelluksen
OLETUSAIKA = 60                 # jos palvelu ei kerro vastauksen käyttöaikaa

_muisti = {}                    # url -> {"data", "vanhenee", "etag", "muutettu"}
_lukko = threading.Lock()       # Flask voi käsitellä pyyntöjä rinnakkain säikeissä


def voimassaoloaika(vastaus):
    """Montako sekuntia vastausta saa käyttää. max-age menee Expires-otsakkeen edelle."""
    for osa in vastaus.headers.get("Cache-Control", "").split(","):
        nimi, _, arvo = osa.strip().partition("=")
        if nimi.lower() == "no-store":
            return 0
        if nimi.lower() == "max-age" and arvo.isdigit():
            return int(arvo)
    expires = vastaus.headers.get("Expires")
    if expires:
        try:
            return max(0, parsedate_to_datetime(expires).timestamp() - time.time())
        except (TypeError, ValueError):
            return 0                # kelvoton Expires tarkoittaa jo vanhentunutta
    return OLETUSAIKA


def hae(url, otsakkeet=None):
    """Palauttaa (data, tila), jossa tila on "välimuisti", "304" tai "haettu"."""
    with _lukko:
        tallennettu = _muisti.get(url)
    if tallennettu and time.time() < tallennettu["vanhenee"]:
        return tallennettu["data"], "välimuisti"

    kaikki = dict(OTSAKKEET, **(otsakkeet or {}))
    if tallennettu:
        if tallennettu["etag"]:
            kaikki["If-None-Match"] = tallennettu["etag"]
        if tallennettu["muutettu"]:
            # Arvo annetaan täsmälleen sellaisena kuin palvelu sen lähetti.
            kaikki["If-Modified-Since"] = tallennettu["muutettu"]

    vastaus = requests.get(url, headers=kaikki, timeout=AIKAKATKAISU)

    if vastaus.status_code == 304 and tallennettu:
        with _lukko:
            tallennettu["vanhenee"] = time.time() + voimassaoloaika(vastaus)
        return tallennettu["data"], "304"

    vastaus.raise_for_status()      # 403, 429, 500 ... poikkeukseksi
    uusi = {
        "data": vastaus.json(),
        "vanhenee": time.time() + voimassaoloaika(vastaus),
        "etag": vastaus.headers.get("ETag"),
        "muutettu": vastaus.headers.get("Last-Modified"),
    }
    with _lukko:
        _muisti[url] = uusi
    return uusi["data"], "haettu"


def jaljella(url):
    """Montako sekuntia välimuistissa oleva vastaus on vielä voimassa."""
    with _lukko:
        tallennettu = _muisti.get(url)
    return max(0, int(tallennettu["vanhenee"] - time.time())) if tallennettu else 0

Oma palvelu kertoo puolestaan selaimelle, kuinka kauan sen vastausta saa käyttää. Kun data on välimuistissa vielä 20 sekuntia, ei selaimenkaan kannata kysyä uudelleen ennen sitä (rajapinta/sovellus.py):

LAHDE = os.environ.get("LAHDE", "http://127.0.0.1:5001/havainto")

app = Flask(__name__)


@app.get("/")
def havainto():
    try:
        data, tila = hae(LAHDE)
    except requests.RequestException as virhe:
        # Ulkoisen palvelun vika ei saa kaataa omaa palvelua.
        app.logger.warning("Haku epäonnistui: %s", virhe)
        return {"virhe": "Havaintoa ei juuri nyt saatu"}, 502
    return (dict(data, lahde=tila), 200,
            {"Cache-Control": f"public, max-age={jaljella(LAHDE)}"})

Käynnistä harjoitusrajapinta lyhyellä voimassaoloajalla ja sovellus toisessa ikkunassa:

VOIMASSA=10 MUUTOSVALI=60 flask --app lahde run --port 5001
flask --app sovellus run

Oikea rajapinta: GitHub

GitHubin REST-rajapinta on hyvä kokeilukohde, koska se on avoin, dokumentoitu tarkasti ja käyttää kaikkia edellä käsiteltyjä otsakkeita:

rajapinta/github.py hakee repositorion tiedot kolme kertaa samalla välimuistilla:

import sys
import time

from valimuisti import hae

GITHUB = {
    "Accept": "application/vnd.github+json",
    "X-GitHub-Api-Version": "2022-11-28",
}

repo = sys.argv[1] if len(sys.argv) > 1 else "python/cpython"
for kierros in range(3):
    data, tila = hae(f"https://api.github.com/repos/{repo}", GITHUB)
    print(f"{tila:10} {data['full_name']}: {data['stargazers_count']} tähteä")
    time.sleep(2)

Toinen samaan tapaan toimiva palvelu on Wikipedian rajapinta, esim. https://fi.wikipedia.org/api/rest_v1/page/summary/Jyväskylä. Wikimedia vaatii User-Agent-käytännössään kuvaavan User-Agent-otsakkeen, jossa on yhteystieto, ja voi estää kirjastojen yleiset oletusarvot.

Cloud Run functions

Googlen pilvifunktiot (Cloud Run functions) ovat Cloud Run -palveluja, joissa kirjoitetaan pelkkä funktio. Palvelu rakentaa funktion ympärille HTTP-palvelimen ja kontin Functions Frameworkin avulla. Pilvifunktio sopii yksittäiseen toimintoon, kuten laskentaan, webhookiin tai tapahtumiin reagoimiseen. Kokonaiselle Flask-sovellukselle sopii paremmin tavallinen Cloud Run -palvelu. Esimerkiksi raskas laskenta, jota muu sovellus tarvitsee harvoin, on sopiva pilvifunktio.

Pilvifunktiot ovat tilattomia. Kun funktiota kutsutaan eikä vapaata instanssia ole, käynnistetään uusi (cold start), mikä hidastaa vastausta. Käynnistetty instanssi pysyy hetken elossa ja palvelee seuraavia kutsuja nopeammin. Kaikki, mitä voi käyttää uudelleen, kuten tietokantayhteydet ja välimuisti, pidetään siksi globaaleissa muuttujissa. Mikään ei kuitenkaan takaa, että seuraava kutsu tulee samalle instanssille. Lue Cloud Run functions best practices.

Malli (pilvifunktio/main.py):

import functions_framework
from cachetools import TTLCache, cached

ENINTAAN = 10000        # hidas algoritmi: suurempi luku varaisi instanssin pitkäksi aikaa

# Globaali muuttuja säilyy instanssin muistissa kutsujen välillä, joten sama
# instanssi voi vastata toistuvaan kyselyyn laskematta uudelleen. Eri
# instansseilla on kuitenkin kullakin oma välimuistinsa.
valimuisti = TTLCache(maxsize=100, ttl=3600)


@cached(valimuisti)
def laske(n):
    """Alkuluvut, jotka ovat pienempiä kuin n.

    Algoritmi on tahallaan hidas, jotta välimuistin vaikutus näkyy.
    Oikeassa sovelluksessa käytettäisiin Eratostheneen seulaa."""
    alkuluvut = []
    for luku in range(2, n):
        if all(luku % jakaja != 0 for jakaja in range(2, luku)):
            alkuluvut.append(luku)
    return alkuluvut


@functions_framework.http
def alkuluvut(request):
    arvo = request.args.get("n", "100")
    # Syöte tulee käyttäjältä: tarkista tyyppi ja koko ennen laskentaa.
    if not arvo.isdigit() or int(arvo) > ENINTAAN:
        return {"virhe": f"n on kokonaisluku väliltä 0–{ENINTAAN}"}, 400
    n = int(arvo)       # int avaimena: "0100" ja "100" ovat sama laskenta
    luvut = laske(n)
    return {"n": n, "lukumaara": len(luvut), "alkuluvut": luvut}

requirements.txt:

functions-framework
cachetools

Kokeile ensin omalla koneella:

cd pilvifunktio
functions-framework --target=alkuluvut --debug
# selaimella http://localhost:8080/?n=10000

Julkaise funktio. --function on funktion nimi main.py-tiedostossa. --max-instances 1 rajaa kustannukset harjoituksessa.

gcloud run deploy ohjaus6-malli --source . --function alkuluvut \
    --base-image python314 --region europe-north1 \
    --allow-unauthenticated --max-instances 1

Cloud SQL

Otetaan käyttöön PostgreSQL-tietokanta Googlen pilvipalvelussa. Lue ensin luennon luvut PostgreSQL, Tärkeimmät erot SQLiteen ja Cloud SQL. Luennossa sama tehdään gcloud-komennoilla, tässä Cloud Consolessa.

Tätä tietokantaa voisi periaatteessa käyttää myös users.jyu.fi:ssä olevalla sovelluksella, mutta IT-palvelut ovat estäneet sen.

Tietokantayhteys ja yhteyspooli

PostgreSQL-tietokantaa käytetään Pythonissa psycopg (versio 3) -kirjastolla. Kyselyt kirjoitetaan kuten SQLitessä, mutta parametrien paikkamerkki on %s. Web-sovelluksessa yhteydet otetaan yhteyspoolista, jotta jokaisella pyynnöllä ei tarvitse avata uutta yhteyttä.

pip install "psycopg[binary]" psycopg-pool

tietokanta/tietokanta.py luo poolin. Yhteystiedot luetaan ympäristömuuttujista, ja Cloud Runissa yhteys muodostetaan Unix-soketin kautta:

"""PostgreSQL-yhteyspooli. Asetukset luetaan ympäristömuuttujista."""

import atexit
import os

from psycopg.conninfo import make_conninfo
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool


def yhteystiedot():
    # Cloud Runissa Cloud SQL näkyy Unix-sokettina hakemistossa /cloudsql/.
    # Omalla koneella yhteys otetaan TCP:llä, esim. Cloud SQL Auth Proxyn kautta.
    instanssi = os.environ.get("CLOUD_SQL_YHTEYS")      # projekti:alue:instanssi
    palvelin = f"/cloudsql/{instanssi}" if instanssi else os.environ.get("DB_HOST", "127.0.0.1")
    return make_conninfo(
        host=palvelin,
        port=os.environ.get("DB_PORT", "5432"),
        dbname=os.environ["DB_NIMI"],
        user=os.environ["DB_KAYTTAJA"],
        password=os.environ["DB_SALASANA"],
    )


pooli = ConnectionPool(
    yhteystiedot(),
    kwargs={"row_factory": dict_row},
    min_size=1,
    max_size=int(os.environ.get("POOLIN_KOKO", "5")),
    check=ConnectionPool.check_connection,
    open=True,
)
atexit.register(pooli.close)

Pooli lainaa yhteyden with-lohkon ajaksi, vahvistaa muutokset (commit) lohkon päättyessä ja palauttaa yhteyden pooliin (tietokanta/sovellus.py):

@app.get("/reseptit")
def reseptit():
    with pooli.connection() as yhteys:
        rivit = yhteys.execute("""
            SELECT r.ReseptiID AS id, r.Nimi AS nimi, r.Henkilomaara AS henkilomaara,
                   l.Nimi AS ruokalaji
            FROM Resepti r
            JOIN Ruokalaji l ON l.RuokalajiID = r.RuokalajiID
            ORDER BY r.Nimi COLLATE "fi-x-icu"
        """).fetchall()
    return rivit
    try:
        with pooli.connection() as yhteys:
            uusi = yhteys.execute("""
                INSERT INTO Resepti (Nimi, Henkilomaara, RuokalajiID)
                VALUES (%s, %s, %s)
                RETURNING ReseptiID AS id
            """, (nimi, henkilomaara, ruokalaji)).fetchone()
    except psycopg.errors.ForeignKeyViolation:
        return {"virhe": "Tarkista tiedot", "kentat": {"ruokalaji": "Tuntematon ruokalaji"}}, 422
    return uusi, 201, {"Location": url_for("resepti", rid=uusi["id"])}

Käynnistä sovellus omalla koneella proxyn kautta. Salasanaa ei kirjoiteta koodiin:

cd tietokanta
export DB_HOST=127.0.0.1 DB_NIMI=ohjaus6 DB_KAYTTAJA=ohjaus6
read -s DB_SALASANA && export DB_SALASANA
flask --app sovellus run

Jos et käytä PostgreSQL-tietokantaasi, pysäytä se. Kurssin jälkeen poista instanssi ja muut Googlen pilveen luomasi resurssit.

Avoin data: XML

Käytetään muiden tuottamia XML-dokumentteja tiedonlähteenä. Pythonin ElementTree-rajapinta on yksinkertaisempi kuin DOM-rajapinta, joka on tuttu TIEA2120-kurssilta. Vieraalta palvelimelta tuleva XML jäsennetään defusedxml-kirjastolla, jolla on sama rajapinta, mutta joka torjuu entiteettihyökkäykset. Lue luennon luvut XML:n jäsentäminen, Nimiavaruudet ja Turvallisuus.

Kilpailun tiedot

Jäsennetään https://appro.mit.jyu.fi/ties4080/ohjaus/ohjaus6/data.xml-tiedosto. Sen rakenne on sama kuin aiemmin käytetyissä JSON-versioissa: juuren alla ovat kilpailun tiedot, rastit-, sarjat- ja tupa-elementit, ja jokaisen sarjan alla sen joukkueet.

Malli (xml/lue_data.py):

import sys

from defusedxml.ElementTree import parse


def teksti(elementti, polku, oletus=""):
    """Alielementin teksti tai oletusarvo, jos elementti puuttuu tai on tyhjä."""
    arvo = elementti.findtext(polku)
    return arvo.strip() if arvo and arvo.strip() else oletus


def main(tiedosto="data.xml"):
    juuri = parse(tiedosto).getroot()

    print(f"{teksti(juuri, 'nimi')}: {teksti(juuri, 'alkuaika')} – {teksti(juuri, 'loppuaika')}")

    rastit = juuri.findall("rastit")
    print(f"Rasteja {len(rastit)}, joista numerottomia:",
          ", ".join(sorted(teksti(r, "koodi") for r in rastit
                           if not teksti(r, "koodi")[:1].isdigit())))

    for sarja in sorted(juuri.findall("sarjat"), key=lambda s: int(teksti(s, "kesto", "0"))):
        print(f"\nSarja {teksti(sarja, 'nimi')}")
        joukkueet = sorted(sarja.findall("joukkueet"), key=lambda j: teksti(j, "nimi").casefold())
        for joukkue in joukkueet:
            jasenet = [j.text.strip() for j in joukkue.findall("jasenet") if j.text]
            print(f"  {teksti(joukkue, 'nimi')} ({', '.join(jasenet)})")

    leimaukset = juuri.findall("tupa")
    print(f"\nRastileimauksia {len(leimaukset)}")


if __name__ == "__main__":
    main(*sys.argv[1:])

Tee lopuksi tiedoista Flask-sovellus, joka tuottaa XHTML-sivun. Mallissa (xml/sovellus.py ja xml/templates/kilpailu.xhtml) data haetaan verkosta enintään kerran viidessä minuutissa.

XML ja nimiavaruudet

XML-dokumentissa voi olla elementtejä useista nimiavaruuksista. XHTML-sivulla voi olla esimerkiksi MathML-kaavoja ja SVG-kuvia. ElementTree tallentaa elementin nimen muodossa {nimiavaruus}nimi, ja haussa etuliitteet kytketään nimiavaruuksiin sanakirjalla. Etuliitteiden ei tarvitse olla samoja kuin dokumentissa. Ratkaisevaa on nimiavaruuden URI. Samanniminen elementti eri nimiavaruudessa on eri elementti.

Tee ohjelma, joka tulostaa esimerkki.xhtml-dokumentista

Malli (xml/nimiavaruudet.py):

import sys

from defusedxml.ElementTree import parse

NA = {
    "h": "http://www.w3.org/1999/xhtml",
    "m": "http://www.w3.org/1998/Math/MathML",
    "s": "http://www.w3.org/2000/svg",
    "xl": "http://www.w3.org/1999/xlink",
}


def main(tiedosto="esimerkki.xhtml"):
    juuri = parse(tiedosto).getroot()
    print("Juuren nimi:", juuri.tag)

    # 1. Kaikkien h2-otsikoiden tekstit
    print("h2:", [h2.text for h2 in juuri.iterfind(".//h:h2", NA)])

    # 2. MathML:n muuttujat (mi-elementit)
    print("mi:", [mi.text for mi in juuri.iterfind(".//m:mi", NA)])

    # 3. SVG-monikulmioiden pisteet
    for monikulmio in juuri.iterfind(".//s:polygon", NA):
        print("polygon:", monikulmio.get("points"))

    # 4. Nimiavaruudessa olevan attribuutin lukeminen: {URI}nimi
    for linkki in juuri.iterfind(".//s:a", NA):
        print("xlink:href:", linkki.get(f"{{{NA['xl']}}}href"))

    # Ilman nimiavaruutta haku ei löydä mitään, koska kaikki elementit
    # ovat XHTML:n nimiavaruudessa.
    print("Ilman nimiavaruutta:", juuri.findall(".//h2"))


if __name__ == "__main__":
    main(*sys.argv[1:])

Rakenteeltaan rikkinäisiä HTML-sivuja (tag soup) ei voi jäsentää XML-jäsentimellä. Niihin käytetään esimerkiksi BeautifulSoup-kirjastoa, jolla on oma rajapintansa, tai lxml.html-moduulia, joka tuottaa ElementTree-yhteensopivan puun. Ennen sivun kaapimista tarkista palvelun käyttöehdot ja robots.txt. Avoin rajapinta on aina parempi vaihtoehto.

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti