Add types and docstrings + upgrade to SQLAlchmey 2
This commit is contained in:
		
							parent
							
								
									82671959bb
								
							
						
					
					
						commit
						5e8d1a99b0
					
				| 
						 | 
				
			
			@ -8,6 +8,8 @@ import flask
 | 
			
		|||
import geoalchemy2
 | 
			
		||||
import sqlalchemy
 | 
			
		||||
from sqlalchemy import and_, or_
 | 
			
		||||
from sqlalchemy.dialects import postgresql
 | 
			
		||||
from sqlalchemy.orm import Mapped
 | 
			
		||||
from sqlalchemy.sql import select
 | 
			
		||||
 | 
			
		||||
from matcher import database, model, wikidata, wikidata_api
 | 
			
		||||
| 
						 | 
				
			
			@ -91,16 +93,22 @@ def make_envelope(bounds: list[float]) -> geoalchemy2.functions.ST_MakeEnvelope:
 | 
			
		|||
    return sqlalchemy.func.ST_MakeEnvelope(*bounds, srid)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def parse_point(point: str) -> tuple[str, str]:
 | 
			
		||||
    """Parse point from PostGIS."""
 | 
			
		||||
    m = re_point.match(point)
 | 
			
		||||
    assert m
 | 
			
		||||
    lon, lat = m.groups()
 | 
			
		||||
    assert lon and lat
 | 
			
		||||
    return (lon, lat)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_bbox_centroid(bbox: list[float]) -> tuple[str, str]:
 | 
			
		||||
    """Get centroid of bounding box."""
 | 
			
		||||
    bbox = make_envelope(bbox)
 | 
			
		||||
    centroid = database.session.query(
 | 
			
		||||
        sqlalchemy.func.ST_AsText(sqlalchemy.func.ST_Centroid(bbox))
 | 
			
		||||
    ).scalar()
 | 
			
		||||
    m = re_point.match(centroid)
 | 
			
		||||
    assert m
 | 
			
		||||
    lon, lat = m.groups()
 | 
			
		||||
    assert lon and lat
 | 
			
		||||
    lon, lat = parse_point(centroid)
 | 
			
		||||
    return (lat, lon)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -117,26 +125,17 @@ def make_envelope_around_point(
 | 
			
		|||
    s = select(
 | 
			
		||||
        [
 | 
			
		||||
            sqlalchemy.func.ST_AsText(
 | 
			
		||||
                sqlalchemy.func.ST_Project(p, distance, sqlalchemy.func.radians(0))
 | 
			
		||||
            ),
 | 
			
		||||
            sqlalchemy.func.ST_AsText(
 | 
			
		||||
                sqlalchemy.func.ST_Project(p, distance, sqlalchemy.func.radians(90))
 | 
			
		||||
            ),
 | 
			
		||||
            sqlalchemy.func.ST_AsText(
 | 
			
		||||
                sqlalchemy.func.ST_Project(p, distance, sqlalchemy.func.radians(180))
 | 
			
		||||
            ),
 | 
			
		||||
            sqlalchemy.func.ST_AsText(
 | 
			
		||||
                sqlalchemy.func.ST_Project(p, distance, sqlalchemy.func.radians(270))
 | 
			
		||||
            ),
 | 
			
		||||
                sqlalchemy.func.ST_Project(p, distance, sqlalchemy.func.radians(deg))
 | 
			
		||||
            )
 | 
			
		||||
            for deg in (0, 90, 180, 270)
 | 
			
		||||
        ]
 | 
			
		||||
    )
 | 
			
		||||
    row = conn.execute(s).fetchone()
 | 
			
		||||
    coords = [[float(v) for v in re_point.match(i).groups()] for i in row]
 | 
			
		||||
    coords = [parse_point(i) for i in conn.execute(s).fetchone()]
 | 
			
		||||
 | 
			
		||||
    north = coords[0][1]
 | 
			
		||||
    east = coords[1][0]
 | 
			
		||||
    south = coords[2][1]
 | 
			
		||||
    west = coords[3][0]
 | 
			
		||||
    north = float(coords[0][1])
 | 
			
		||||
    east = float(coords[1][0])
 | 
			
		||||
    south = float(coords[2][1])
 | 
			
		||||
    west = float(coords[3][0])
 | 
			
		||||
 | 
			
		||||
    return sqlalchemy.func.ST_MakeEnvelope(west, south, east, north, srid)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -148,10 +147,15 @@ def drop_way_area(tags: TagsType) -> TagsType:
 | 
			
		|||
    return tags
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_part_of(table_name, src_id, bbox):
 | 
			
		||||
def get_part_of(
 | 
			
		||||
    table_name: str, src_id: int, bbox: geoalchemy2.functions.ST_MakeEnvelope
 | 
			
		||||
) -> list[dict[str, typing.Any]]:
 | 
			
		||||
    """Get part of."""
 | 
			
		||||
    table_map = {"point": point, "line": line, "polygon": polygon}
 | 
			
		||||
    table_alias = table_map[table_name].alias()
 | 
			
		||||
 | 
			
		||||
    tags: Mapped[postgresql.HSTORE] = polygon.c.tags
 | 
			
		||||
 | 
			
		||||
    s = (
 | 
			
		||||
        select(
 | 
			
		||||
            [
 | 
			
		||||
| 
						 | 
				
			
			@ -165,11 +169,8 @@ def get_part_of(table_name, src_id, bbox):
 | 
			
		|||
                sqlalchemy.func.ST_Intersects(bbox, polygon.c.way),
 | 
			
		||||
                sqlalchemy.func.ST_Covers(polygon.c.way, table_alias.c.way),
 | 
			
		||||
                table_alias.c.osm_id == src_id,
 | 
			
		||||
                polygon.c.tags.has_key("name"),
 | 
			
		||||
                or_(
 | 
			
		||||
                    polygon.c.tags.has_key("landuse"),
 | 
			
		||||
                    polygon.c.tags.has_key("amenity"),
 | 
			
		||||
                ),
 | 
			
		||||
                tags.has_key("name"),
 | 
			
		||||
                or_(tags.has_key("landuse"), tags.has_key("amenity")),
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        .group_by(polygon.c.osm_id, polygon.c.tags)
 | 
			
		||||
| 
						 | 
				
			
			@ -228,6 +229,7 @@ def get_isa_count(items: list[model.Item]) -> list[tuple[str, int]]:
 | 
			
		|||
            if not isa:
 | 
			
		||||
                print("missing IsA:", item.qid)
 | 
			
		||||
                continue
 | 
			
		||||
            assert isinstance(isa, dict) and isinstance(isa["id"], str)
 | 
			
		||||
            isa_count[isa["id"]] += 1
 | 
			
		||||
 | 
			
		||||
    return isa_count.most_common()
 | 
			
		||||
| 
						 | 
				
			
			@ -920,7 +922,7 @@ def find_osm_candidates(item, limit=80, max_distance=450, names=None):
 | 
			
		|||
            "geojson": json.loads(geojson),
 | 
			
		||||
            "presets": get_presets_from_tags(shape, tags),
 | 
			
		||||
            "address_list": address_list,
 | 
			
		||||
            "centroid": list(reversed(re_point.match(centroid).groups())),
 | 
			
		||||
            "centroid": list(reversed(parse_point(centroid))),
 | 
			
		||||
        }
 | 
			
		||||
        if area is not None:
 | 
			
		||||
            cur["area"] = area
 | 
			
		||||
| 
						 | 
				
			
			@ -980,23 +982,23 @@ def check_is_street_number_first(latlng):
 | 
			
		|||
    flask.g.street_number_first = is_street_number_first(*latlng)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ItemDetailType(typing.TypedDict):
 | 
			
		||||
class ItemDetailType(typing.TypedDict, total=False):
 | 
			
		||||
    """Details of an item as a dict."""
 | 
			
		||||
 | 
			
		||||
    qid: str
 | 
			
		||||
    label: str
 | 
			
		||||
    description: str
 | 
			
		||||
    description: str | None
 | 
			
		||||
    markers: list[dict[str, float]]
 | 
			
		||||
    image_list: list[str]
 | 
			
		||||
    street_address: list[str]
 | 
			
		||||
    isa_list: list[dict[str, str]]
 | 
			
		||||
    closed: bool
 | 
			
		||||
    closed: list[str]
 | 
			
		||||
    inception: str
 | 
			
		||||
    p1619: str
 | 
			
		||||
    p576: str
 | 
			
		||||
    heritage_designation: str
 | 
			
		||||
    wikipedia: dict[str, str]
 | 
			
		||||
    identifiers: list[str]
 | 
			
		||||
    wikipedia: list[dict[str, str]]
 | 
			
		||||
    identifiers: dict[str, list[str]]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def item_detail(item: model.Item) -> ItemDetailType:
 | 
			
		||||
| 
						 | 
				
			
			@ -1036,7 +1038,7 @@ def item_detail(item: model.Item) -> ItemDetailType:
 | 
			
		|||
        if site.endswith("wiki") and len(site) < 8
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    d = {
 | 
			
		||||
    d: ItemDetailType = {
 | 
			
		||||
        "qid": item.qid,
 | 
			
		||||
        "label": item.label(),
 | 
			
		||||
        "description": item.description(),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,6 @@
 | 
			
		|||
"""Database functions."""
 | 
			
		||||
"""Database."""
 | 
			
		||||
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
 | 
			
		||||
import flask
 | 
			
		||||
import sqlalchemy
 | 
			
		||||
| 
						 | 
				
			
			@ -8,7 +10,7 @@ from sqlalchemy.orm import scoped_session, sessionmaker
 | 
			
		|||
 | 
			
		||||
session: sqlalchemy.orm.scoping.scoped_session = scoped_session(sessionmaker())
 | 
			
		||||
 | 
			
		||||
timeout = 20_000  # 20 seconds
 | 
			
		||||
timeout = 2_000  # 20 seconds
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def init_db(db_url: str, echo: bool = False) -> None:
 | 
			
		||||
| 
						 | 
				
			
			@ -17,7 +19,7 @@ def init_db(db_url: str, echo: bool = False) -> None:
 | 
			
		|||
 | 
			
		||||
 | 
			
		||||
def get_engine(db_url: str, echo: bool = False) -> sqlalchemy.engine.base.Engine:
 | 
			
		||||
    """Create an engine objcet."""
 | 
			
		||||
    """Create an engine object."""
 | 
			
		||||
    return create_engine(
 | 
			
		||||
        db_url,
 | 
			
		||||
        pool_recycle=3600,
 | 
			
		||||
| 
						 | 
				
			
			@ -40,9 +42,10 @@ def init_app(app: flask.app.Flask, echo: bool = False) -> None:
 | 
			
		|||
    session.configure(bind=get_engine(db_url, echo=echo))
 | 
			
		||||
 | 
			
		||||
    @app.teardown_appcontext
 | 
			
		||||
    def shutdown_session(exception: Exception | None = None) -> None:
 | 
			
		||||
    def shutdown_session(exception: BaseException | None = None) -> None:
 | 
			
		||||
        session.remove()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def now_utc():
 | 
			
		||||
def now_utc() -> sqlalchemy.sql.functions.Function[datetime]:
 | 
			
		||||
    """Now with UTC timezone."""
 | 
			
		||||
    return func.timezone("utc", func.now())
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,14 +1,17 @@
 | 
			
		|||
from flask import g
 | 
			
		||||
from . import user_agent_headers, database, osm_oauth, mail
 | 
			
		||||
from .model import Changeset
 | 
			
		||||
import requests
 | 
			
		||||
import html
 | 
			
		||||
 | 
			
		||||
import requests
 | 
			
		||||
from flask import g
 | 
			
		||||
 | 
			
		||||
from . import database, mail, osm_oauth, user_agent_headers
 | 
			
		||||
from .model import Changeset
 | 
			
		||||
 | 
			
		||||
really_save = True
 | 
			
		||||
osm_api_base = "https://api.openstreetmap.org/api/0.6"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def new_changeset(comment: str) -> str:
 | 
			
		||||
    """XML for a new changeset."""
 | 
			
		||||
    return f"""
 | 
			
		||||
<osm>
 | 
			
		||||
  <changeset>
 | 
			
		||||
| 
						 | 
				
			
			@ -18,11 +21,12 @@ def new_changeset(comment: str) -> str:
 | 
			
		|||
</osm>"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def osm_request(path, **kwargs):
 | 
			
		||||
def osm_request(path, **kwargs) -> requests.Response:
 | 
			
		||||
    return osm_oauth.api_put_request(path, **kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_changeset(changeset):
 | 
			
		||||
def create_changeset(changeset: str) -> requests.Response:
 | 
			
		||||
    """Create new changeset."""
 | 
			
		||||
    try:
 | 
			
		||||
        return osm_request("/changeset/create", data=changeset.encode("utf-8"))
 | 
			
		||||
    except requests.exceptions.HTTPError as r:
 | 
			
		||||
| 
						 | 
				
			
			@ -31,11 +35,15 @@ def create_changeset(changeset):
 | 
			
		|||
        raise
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def close_changeset(changeset_id):
 | 
			
		||||
def close_changeset(changeset_id: int) -> requests.Response:
 | 
			
		||||
    """Close changeset."""
 | 
			
		||||
    return osm_request(f"/changeset/{changeset_id}/close")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def save_element(osm_type, osm_id, element_data):
 | 
			
		||||
def save_element(
 | 
			
		||||
    osm_type: str, osm_id: int, element_data: str
 | 
			
		||||
) -> requests.Response | None:
 | 
			
		||||
    """Upload new version of object to OSM map."""
 | 
			
		||||
    osm_path = f"/{osm_type}/{osm_id}"
 | 
			
		||||
    r = osm_request(osm_path, data=element_data)
 | 
			
		||||
    reply = r.text.strip()
 | 
			
		||||
| 
						 | 
				
			
			@ -56,9 +64,12 @@ error:
 | 
			
		|||
 | 
			
		||||
    mail.send_mail(subject, body)
 | 
			
		||||
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def record_changeset(**kwargs):
 | 
			
		||||
    change = Changeset(created=database.now_utc(), **kwargs)
 | 
			
		||||
 | 
			
		||||
def record_changeset(**kwargs: str) -> Changeset:
 | 
			
		||||
    """Record changeset in the database."""
 | 
			
		||||
    change: Changeset = Changeset(created=database.now_utc(), **kwargs)
 | 
			
		||||
 | 
			
		||||
    database.session.add(change)
 | 
			
		||||
    database.session.commit()
 | 
			
		||||
| 
						 | 
				
			
			@ -66,6 +77,7 @@ def record_changeset(**kwargs):
 | 
			
		|||
    return change
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_existing(osm_type, osm_id):
 | 
			
		||||
def get_existing(osm_type: str, osm_id: int) -> requests.Response:
 | 
			
		||||
    """Get existing OSM object."""
 | 
			
		||||
    url = f"{osm_api_base}/{osm_type}/{osm_id}"
 | 
			
		||||
    return requests.get(url, headers=user_agent_headers())
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										148
									
								
								matcher/model.py
									
									
									
									
									
								
							
							
						
						
									
										148
									
								
								matcher/model.py
									
									
									
									
									
								
							| 
						 | 
				
			
			@ -1,3 +1,4 @@
 | 
			
		|||
import abc
 | 
			
		||||
import json
 | 
			
		||||
import re
 | 
			
		||||
import typing
 | 
			
		||||
| 
						 | 
				
			
			@ -12,13 +13,21 @@ from sqlalchemy.dialects import postgresql
 | 
			
		|||
from sqlalchemy.ext.associationproxy import association_proxy
 | 
			
		||||
from sqlalchemy.ext.declarative import declared_attr
 | 
			
		||||
from sqlalchemy.ext.hybrid import hybrid_property
 | 
			
		||||
from sqlalchemy.orm import backref, column_property, deferred, registry, relationship
 | 
			
		||||
from sqlalchemy.orm import (
 | 
			
		||||
    Mapped,
 | 
			
		||||
    QueryPropertyDescriptor,
 | 
			
		||||
    backref,
 | 
			
		||||
    column_property,
 | 
			
		||||
    deferred,
 | 
			
		||||
    registry,
 | 
			
		||||
    relationship,
 | 
			
		||||
)
 | 
			
		||||
from sqlalchemy.orm.collections import attribute_mapped_collection
 | 
			
		||||
from sqlalchemy.orm.decl_api import DeclarativeMeta
 | 
			
		||||
from sqlalchemy.schema import Column, ForeignKey
 | 
			
		||||
from sqlalchemy.types import BigInteger, Boolean, DateTime, Float, Integer, String, Text
 | 
			
		||||
 | 
			
		||||
from . import mail, utils, wikidata
 | 
			
		||||
from . import mail, utils, wikidata, wikidata_api
 | 
			
		||||
from .database import now_utc, session
 | 
			
		||||
 | 
			
		||||
mapper_registry = registry()
 | 
			
		||||
| 
						 | 
				
			
			@ -36,7 +45,7 @@ class Base(metaclass=DeclarativeMeta):
 | 
			
		|||
 | 
			
		||||
    registry = mapper_registry
 | 
			
		||||
    metadata = mapper_registry.metadata
 | 
			
		||||
    query = session.query_property()
 | 
			
		||||
    query: QueryPropertyDescriptor = session.query_property()
 | 
			
		||||
 | 
			
		||||
    __init__ = mapper_registry.constructor
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -98,12 +107,12 @@ class Item(Base):
 | 
			
		|||
    sitelinks = Column(postgresql.JSONB)
 | 
			
		||||
    claims = Column(postgresql.JSONB, nullable=False)
 | 
			
		||||
    lastrevid = Column(Integer, nullable=False, unique=True)
 | 
			
		||||
    locations = relationship(
 | 
			
		||||
    locations: Mapped[list["ItemLocation"]] = relationship(
 | 
			
		||||
        "ItemLocation", cascade="all, delete-orphan", backref="item"
 | 
			
		||||
    )
 | 
			
		||||
    qid = column_property("Q" + cast_to_string(item_id))
 | 
			
		||||
    qid: Mapped[str] = column_property("Q" + cast_to_string(item_id))
 | 
			
		||||
 | 
			
		||||
    wiki_extracts = relationship(
 | 
			
		||||
    wiki_extracts: Mapped[list["Extract"]] = relationship(
 | 
			
		||||
        "Extract",
 | 
			
		||||
        collection_class=attribute_mapped_collection("site"),
 | 
			
		||||
        cascade="save-update, merge, delete, delete-orphan",
 | 
			
		||||
| 
						 | 
				
			
			@ -158,14 +167,15 @@ class Item(Base):
 | 
			
		|||
        if d_list:
 | 
			
		||||
            return d_list[0]["value"]
 | 
			
		||||
 | 
			
		||||
    def get_aliases(self, lang="en"):
 | 
			
		||||
    def get_aliases(self, lang: str = "en") -> list[str]:
 | 
			
		||||
        """Get aliases."""
 | 
			
		||||
        if lang not in self.aliases:
 | 
			
		||||
            if "en" not in self.aliases:
 | 
			
		||||
                return []
 | 
			
		||||
            lang = "en"
 | 
			
		||||
        return [a["value"] for a in self.aliases[lang]]
 | 
			
		||||
 | 
			
		||||
    def get_part_of_names(self):
 | 
			
		||||
    def get_part_of_names(self) -> set[str]:
 | 
			
		||||
        if not self.claims:
 | 
			
		||||
            return set()
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -186,11 +196,14 @@ class Item(Base):
 | 
			
		|||
        return part_of_names
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def entity(self):
 | 
			
		||||
    def entity(self) -> wikidata_api.EntityType:
 | 
			
		||||
        """Entity."""
 | 
			
		||||
        keys = ["labels", "aliases", "descriptions", "sitelinks", "claims"]
 | 
			
		||||
        return {key: getattr(self, key) for key in keys}
 | 
			
		||||
        return typing.cast(
 | 
			
		||||
            wikidata_api.EntityType, {key: getattr(self, key) for key in keys}
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def names(self, check_part_of=True):
 | 
			
		||||
    def names(self, check_part_of: bool = True) -> dict[str, list[str]] | None:
 | 
			
		||||
        part_of_names = self.get_part_of_names() if check_part_of else set()
 | 
			
		||||
 | 
			
		||||
        d = wikidata.names_from_entity(self.entity) or defaultdict(list)
 | 
			
		||||
| 
						 | 
				
			
			@ -258,7 +271,8 @@ class Item(Base):
 | 
			
		|||
        """Get QIDs of items listed instance of (P31) property."""
 | 
			
		||||
        return [typing.cast(str, isa["id"]) for isa in self.get_isa()]
 | 
			
		||||
 | 
			
		||||
    def is_street(self, isa_qids=None):
 | 
			
		||||
    def is_street(self, isa_qids: typing.Collection[str] | None = None) -> bool:
 | 
			
		||||
        """Item represents a street."""
 | 
			
		||||
        if isa_qids is None:
 | 
			
		||||
            isa_qids = self.get_isa_qids()
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -272,7 +286,8 @@ class Item(Base):
 | 
			
		|||
        }
 | 
			
		||||
        return bool(matching_types & set(isa_qids))
 | 
			
		||||
 | 
			
		||||
    def is_watercourse(self, isa_qids=None):
 | 
			
		||||
    def is_watercourse(self, isa_qids: typing.Collection[str] | None = None) -> bool:
 | 
			
		||||
        """Item represents a watercourse."""
 | 
			
		||||
        if isa_qids is None:
 | 
			
		||||
            isa_qids = self.get_isa_qids()
 | 
			
		||||
        matching_types = {
 | 
			
		||||
| 
						 | 
				
			
			@ -368,7 +383,7 @@ class Item(Base):
 | 
			
		|||
 | 
			
		||||
        return text[: first_end_p_tag + len(close_tag)]
 | 
			
		||||
 | 
			
		||||
    def get_identifiers_tags(self):
 | 
			
		||||
    def get_identifiers_tags(self) -> dict[str, list[tuple[list[str], str]]]:
 | 
			
		||||
        tags = defaultdict(list)
 | 
			
		||||
        for claim, osm_keys, label in property_map:
 | 
			
		||||
            values = [
 | 
			
		||||
| 
						 | 
				
			
			@ -386,7 +401,7 @@ class Item(Base):
 | 
			
		|||
                tags[osm_key].append((values, label))
 | 
			
		||||
        return dict(tags)
 | 
			
		||||
 | 
			
		||||
    def get_identifiers(self):
 | 
			
		||||
    def get_identifiers(self) -> dict[str, list[str]]:
 | 
			
		||||
        ret = {}
 | 
			
		||||
        for claim, osm_keys, label in property_map:
 | 
			
		||||
            values = [
 | 
			
		||||
| 
						 | 
				
			
			@ -420,8 +435,8 @@ class ItemIsA(Base):
 | 
			
		|||
    item_id = Column(Integer, ForeignKey("item.item_id"), primary_key=True)
 | 
			
		||||
    isa_id = Column(Integer, ForeignKey("item.item_id"), primary_key=True)
 | 
			
		||||
 | 
			
		||||
    item = relationship("Item", foreign_keys=[item_id])
 | 
			
		||||
    isa = relationship("Item", foreign_keys=[isa_id])
 | 
			
		||||
    item: Mapped[Item] = relationship("Item", foreign_keys=[item_id])
 | 
			
		||||
    isa: Mapped[Item] = relationship("Item", foreign_keys=[isa_id])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ItemLocation(Base):
 | 
			
		||||
| 
						 | 
				
			
			@ -458,7 +473,9 @@ def location_objects(
 | 
			
		|||
    return locations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MapMixin:
 | 
			
		||||
class MapBase(Base):
 | 
			
		||||
    """Map base class."""
 | 
			
		||||
 | 
			
		||||
    @declared_attr
 | 
			
		||||
    def __tablename__(cls):
 | 
			
		||||
        return "planet_osm_" + cls.__name__.lower()
 | 
			
		||||
| 
						 | 
				
			
			@ -468,7 +485,7 @@ class MapMixin:
 | 
			
		|||
    admin_level = Column(String)
 | 
			
		||||
    boundary = Column(String)
 | 
			
		||||
 | 
			
		||||
    tags = Column(postgresql.HSTORE)
 | 
			
		||||
    tags: Mapped[postgresql.HSTORE]
 | 
			
		||||
 | 
			
		||||
    @declared_attr
 | 
			
		||||
    def way(cls):
 | 
			
		||||
| 
						 | 
				
			
			@ -477,67 +494,92 @@ class MapMixin:
 | 
			
		|||
        )
 | 
			
		||||
 | 
			
		||||
    @declared_attr
 | 
			
		||||
    def kml(cls):
 | 
			
		||||
    def kml(cls) -> sqlalchemy.orm.properties.ColumnProperty:
 | 
			
		||||
        """Get object in KML format."""
 | 
			
		||||
        return column_property(func.ST_AsKML(cls.way), deferred=True)
 | 
			
		||||
 | 
			
		||||
    @declared_attr
 | 
			
		||||
    def geojson_str(cls):
 | 
			
		||||
    def geojson_str(cls) -> sqlalchemy.orm.properties.ColumnProperty:
 | 
			
		||||
        """Get object as GeoJSON string."""
 | 
			
		||||
        return column_property(
 | 
			
		||||
            func.ST_AsGeoJSON(cls.way, maxdecimaldigits=6), deferred=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @declared_attr
 | 
			
		||||
    def as_EWKT(cls):
 | 
			
		||||
    def as_EWKT(cls) -> sqlalchemy.orm.properties.ColumnProperty:
 | 
			
		||||
        """As EWKT."""
 | 
			
		||||
        return column_property(func.ST_AsEWKT(cls.way), deferred=True)
 | 
			
		||||
 | 
			
		||||
    @hybrid_property
 | 
			
		||||
    def has_street_address(self):
 | 
			
		||||
    def has_street_address(self) -> bool:
 | 
			
		||||
        """Object has street address."""
 | 
			
		||||
        return "addr:housenumber" in self.tags and "addr:street" in self.tags
 | 
			
		||||
 | 
			
		||||
    def display_name(self):
 | 
			
		||||
    def display_name(self) -> str:
 | 
			
		||||
        """Name for display."""
 | 
			
		||||
        for key in "bridge:name", "tunnel:name", "lock_name":
 | 
			
		||||
            if key in self.tags:
 | 
			
		||||
                return self.tags[key]
 | 
			
		||||
                return typing.cast(str, self.tags[key])
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
            self.name or self.tags.get("addr:housename") or self.tags.get("inscription")
 | 
			
		||||
        return typing.cast(
 | 
			
		||||
            str,
 | 
			
		||||
            self.name
 | 
			
		||||
            or self.tags.get("addr:housename")
 | 
			
		||||
            or self.tags.get("inscription"),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def geojson(self):
 | 
			
		||||
        return json.loads(self.geojson_str)
 | 
			
		||||
    def geojson(self) -> dict[str, typing.Any]:
 | 
			
		||||
        """Object GeoJSON parsed into Python data structure."""
 | 
			
		||||
        return typing.cast(dict[str, typing.Any], json.loads(self.geojson_str))
 | 
			
		||||
 | 
			
		||||
    def get_centroid(self):
 | 
			
		||||
    def get_centroid(self) -> tuple[float, float]:
 | 
			
		||||
        """Centroid."""
 | 
			
		||||
        centroid = session.query(func.ST_AsText(func.ST_Centroid(self.way))).scalar()
 | 
			
		||||
        lon, lat = re_point.match(centroid).groups()
 | 
			
		||||
        assert centroid
 | 
			
		||||
        assert (m := re_point.match(centroid))
 | 
			
		||||
        lon, lat = m.groups()
 | 
			
		||||
        return (float(lat), float(lon))
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def coords_within(cls, lat, lon):
 | 
			
		||||
    def coords_within(cls, lat: float, lon: float):
 | 
			
		||||
        point = func.ST_SetSRID(func.ST_MakePoint(lon, lat), 4326)
 | 
			
		||||
        return cls.query.filter(
 | 
			
		||||
            cls.admin_level.isnot(None), func.ST_Within(point, cls.way)
 | 
			
		||||
        ).order_by(cls.area)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def id(self):
 | 
			
		||||
    def id(self) -> int:
 | 
			
		||||
        """OSM id."""
 | 
			
		||||
        return abs(self.src_id)  # relations have negative IDs
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def identifier(self):
 | 
			
		||||
    @abc.abstractmethod
 | 
			
		||||
    def type(self) -> str:
 | 
			
		||||
        """OSM type."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def identifier(self) -> str:
 | 
			
		||||
        """OSM identifier."""
 | 
			
		||||
        return f"{self.type}/{self.id}"
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def osm_url(self):
 | 
			
		||||
        """OSM URL."""
 | 
			
		||||
        return f"https://www.openstreetmap.org/{self.type}/{self.id}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Point(MapMixin, Base):
 | 
			
		||||
class Point(MapBase):
 | 
			
		||||
    """OSM planet point."""
 | 
			
		||||
 | 
			
		||||
    type = "node"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Line(MapMixin, Base):
 | 
			
		||||
class Line(MapBase):
 | 
			
		||||
    """OSM planet line."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def type(self):
 | 
			
		||||
    def type(self) -> str:
 | 
			
		||||
        """OSM type."""
 | 
			
		||||
        return "way" if self.src_id > 0 else "relation"
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
| 
						 | 
				
			
			@ -546,7 +588,9 @@ class Line(MapMixin, Base):
 | 
			
		|||
        return cls.query.get(src_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Polygon(MapMixin, Base):
 | 
			
		||||
class Polygon(MapBase):
 | 
			
		||||
    """OSM planet polygon."""
 | 
			
		||||
 | 
			
		||||
    way_area = Column(Float)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
| 
						 | 
				
			
			@ -560,13 +604,15 @@ class Polygon(MapMixin, Base):
 | 
			
		|||
        return "way" if self.src_id > 0 else "relation"
 | 
			
		||||
 | 
			
		||||
    @declared_attr
 | 
			
		||||
    def area(cls):
 | 
			
		||||
    def area(cls) -> sqlalchemy.orm.properties.ColumnProperty:
 | 
			
		||||
        """Polygon area."""
 | 
			
		||||
        return column_property(func.ST_Area(cls.way, False), deferred=True)
 | 
			
		||||
 | 
			
		||||
    @hybrid_property
 | 
			
		||||
    def area_in_sq_km(self) -> float:
 | 
			
		||||
        """Size of area in square km."""
 | 
			
		||||
        return self.area / (1000 * 1000)
 | 
			
		||||
        area: float = self.area
 | 
			
		||||
        return area / (1000 * 1000)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class User(Base, UserMixin):
 | 
			
		||||
| 
						 | 
				
			
			@ -601,6 +647,8 @@ class User(Base, UserMixin):
 | 
			
		|||
 | 
			
		||||
 | 
			
		||||
class EditSession(Base):
 | 
			
		||||
    """Edit session."""
 | 
			
		||||
 | 
			
		||||
    __tablename__ = "edit_session"
 | 
			
		||||
    id = Column(Integer, primary_key=True)
 | 
			
		||||
    user_id = Column(Integer, ForeignKey(User.id))
 | 
			
		||||
| 
						 | 
				
			
			@ -608,8 +656,10 @@ class EditSession(Base):
 | 
			
		|||
    edit_list = Column(postgresql.JSONB)
 | 
			
		||||
    comment = Column(String)
 | 
			
		||||
 | 
			
		||||
    user = relationship("User")
 | 
			
		||||
    changeset = relationship("Changeset", back_populates="edit_session", uselist=False)
 | 
			
		||||
    user: Mapped[User] = relationship("User")
 | 
			
		||||
    changeset: Mapped["Changeset"] = relationship(
 | 
			
		||||
        "Changeset", back_populates="edit_session", uselist=False
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Changeset(Base):
 | 
			
		||||
| 
						 | 
				
			
			@ -623,14 +673,16 @@ class Changeset(Base):
 | 
			
		|||
    update_count = Column(Integer, nullable=False)
 | 
			
		||||
    edit_session_id = Column(Integer, ForeignKey(EditSession.id))
 | 
			
		||||
 | 
			
		||||
    user = relationship(
 | 
			
		||||
    user: Mapped[User] = relationship(
 | 
			
		||||
        "User",
 | 
			
		||||
        backref=backref(
 | 
			
		||||
            "changesets", lazy="dynamic", order_by="Changeset.created.desc()"
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    edit_session = relationship("EditSession", back_populates="changeset")
 | 
			
		||||
    edit_session: Mapped[EditSession] = relationship(
 | 
			
		||||
        "EditSession", back_populates="changeset"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ChangesetEdit(Base):
 | 
			
		||||
| 
						 | 
				
			
			@ -644,7 +696,9 @@ class ChangesetEdit(Base):
 | 
			
		|||
    osm_type = Column(osm_type_enum, primary_key=True)
 | 
			
		||||
    saved = Column(DateTime, default=now_utc(), nullable=False)
 | 
			
		||||
 | 
			
		||||
    changeset = relationship("Changeset", backref=backref("edits", lazy="dynamic"))
 | 
			
		||||
    changeset: Mapped[Changeset] = relationship(
 | 
			
		||||
        "Changeset", backref=backref("edits", lazy="dynamic")
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SkipIsA(Base):
 | 
			
		||||
| 
						 | 
				
			
			@ -654,7 +708,7 @@ class SkipIsA(Base):
 | 
			
		|||
    item_id = Column(Integer, ForeignKey("item.item_id"), primary_key=True)
 | 
			
		||||
    qid = column_property("Q" + cast_to_string(item_id))
 | 
			
		||||
 | 
			
		||||
    item = relationship("Item")
 | 
			
		||||
    item: Mapped[Item] = relationship("Item")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ItemExtraKeys(Base):
 | 
			
		||||
| 
						 | 
				
			
			@ -666,7 +720,7 @@ class ItemExtraKeys(Base):
 | 
			
		|||
    note = Column(String)
 | 
			
		||||
    qid = column_property("Q" + cast_to_string(item_id))
 | 
			
		||||
 | 
			
		||||
    item = relationship("Item")
 | 
			
		||||
    item: Mapped[Item] = relationship("Item")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Extract(Base):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,17 +1,24 @@
 | 
			
		|||
from collections import OrderedDict
 | 
			
		||||
"""Nominatim."""
 | 
			
		||||
 | 
			
		||||
import json
 | 
			
		||||
import typing
 | 
			
		||||
from collections import OrderedDict
 | 
			
		||||
 | 
			
		||||
import requests
 | 
			
		||||
 | 
			
		||||
from . import CallParams
 | 
			
		||||
 | 
			
		||||
Hit = dict[str, typing.Any]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SearchError(Exception):
 | 
			
		||||
    pass
 | 
			
		||||
    """Search error."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def lookup_with_params(**kwargs):
 | 
			
		||||
def lookup_with_params(**kwargs: str) -> list[Hit]:
 | 
			
		||||
    url = "http://nominatim.openstreetmap.org/search"
 | 
			
		||||
 | 
			
		||||
    params = {
 | 
			
		||||
    params: CallParams = {
 | 
			
		||||
        "format": "jsonv2",
 | 
			
		||||
        "addressdetails": 1,
 | 
			
		||||
        "extratags": 1,
 | 
			
		||||
| 
						 | 
				
			
			@ -26,21 +33,24 @@ def lookup_with_params(**kwargs):
 | 
			
		|||
        raise SearchError
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        return json.loads(r.text, object_pairs_hook=OrderedDict)
 | 
			
		||||
        reply: list[Hit] = json.loads(r.text, object_pairs_hook=OrderedDict)
 | 
			
		||||
        return reply
 | 
			
		||||
    except json.decoder.JSONDecodeError:
 | 
			
		||||
        raise SearchError(r)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def lookup(q):
 | 
			
		||||
def lookup(q: str) -> list[Hit]:
 | 
			
		||||
    """Nominatim search."""
 | 
			
		||||
    return lookup_with_params(q=q)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_us_county(county, state):
 | 
			
		||||
def get_us_county(county: str, state: str) -> Hit | None:
 | 
			
		||||
    """Search for US county and return resulting hit."""
 | 
			
		||||
    if " " not in county and "county" not in county:
 | 
			
		||||
        county += " county"
 | 
			
		||||
    results = lookup(q="{}, {}".format(county, state))
 | 
			
		||||
 | 
			
		||||
    def pred(hit):
 | 
			
		||||
    def pred(hit: Hit) -> typing.TypeGuard[Hit]:
 | 
			
		||||
        return (
 | 
			
		||||
            "osm_type" in hit
 | 
			
		||||
            and hit["osm_type"] != "node"
 | 
			
		||||
| 
						 | 
				
			
			@ -50,7 +60,8 @@ def get_us_county(county, state):
 | 
			
		|||
    return next(filter(pred, results), None)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_us_city(name, state):
 | 
			
		||||
def get_us_city(name: str, state: str) -> Hit | None:
 | 
			
		||||
    """Search for US city and return resulting hit."""
 | 
			
		||||
    results = lookup_with_params(city=name, state=state)
 | 
			
		||||
    if len(results) != 1:
 | 
			
		||||
        results = [
 | 
			
		||||
| 
						 | 
				
			
			@ -58,29 +69,32 @@ def get_us_city(name, state):
 | 
			
		|||
        ]
 | 
			
		||||
        if len(results) != 1:
 | 
			
		||||
            print("more than one")
 | 
			
		||||
            return
 | 
			
		||||
            return None
 | 
			
		||||
    hit = results[0]
 | 
			
		||||
    if hit["type"] not in ("administrative", "city"):
 | 
			
		||||
        print("not a city")
 | 
			
		||||
        return
 | 
			
		||||
        return None
 | 
			
		||||
    if hit["osm_type"] == "node":
 | 
			
		||||
        print("node")
 | 
			
		||||
        return
 | 
			
		||||
        return None
 | 
			
		||||
    if not hit["display_name"].startswith(name):
 | 
			
		||||
        print("wrong name")
 | 
			
		||||
        return
 | 
			
		||||
        return None
 | 
			
		||||
    assert "osm_type" in hit and "osm_id" in hit and "geotext" in hit
 | 
			
		||||
    return hit
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_hit_name(hit):
 | 
			
		||||
def get_hit_name(hit: Hit) -> str:
 | 
			
		||||
    """Get name from hit."""
 | 
			
		||||
    address = hit.get("address")
 | 
			
		||||
    if not address:
 | 
			
		||||
        assert isinstance(hit["display_name"], str)
 | 
			
		||||
        return hit["display_name"]
 | 
			
		||||
 | 
			
		||||
    address_values = list(address.values())
 | 
			
		||||
    n1 = address_values[0]
 | 
			
		||||
    if len(address) == 1:
 | 
			
		||||
        assert isinstance(n1, str)
 | 
			
		||||
        return n1
 | 
			
		||||
 | 
			
		||||
    country = address.pop("country", None)
 | 
			
		||||
| 
						 | 
				
			
			@ -102,13 +116,15 @@ def get_hit_name(hit):
 | 
			
		|||
        return f"{n1}, {n2}, {country}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_hit_label(hit):
 | 
			
		||||
def get_hit_label(hit: Hit) -> str:
 | 
			
		||||
    """Parse hit and generate label."""
 | 
			
		||||
    tags = hit["extratags"]
 | 
			
		||||
    designation = tags.get("designation")
 | 
			
		||||
    category = hit["category"]
 | 
			
		||||
    hit_type = hit["type"]
 | 
			
		||||
 | 
			
		||||
    if designation:
 | 
			
		||||
        assert isinstance(designation, str)
 | 
			
		||||
        return designation.replace("_", " ")
 | 
			
		||||
 | 
			
		||||
    if category == "boundary" and hit_type == "administrative":
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,14 +1,15 @@
 | 
			
		|||
from flask import current_app, session
 | 
			
		||||
from requests_oauthlib import OAuth1Session
 | 
			
		||||
from urllib.parse import urlencode
 | 
			
		||||
"""OSM Authentication."""
 | 
			
		||||
 | 
			
		||||
import typing
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from flask import g
 | 
			
		||||
 | 
			
		||||
from .model import User
 | 
			
		||||
 | 
			
		||||
from . import user_agent_headers
 | 
			
		||||
from urllib.parse import urlencode
 | 
			
		||||
 | 
			
		||||
import lxml.etree
 | 
			
		||||
from flask import current_app, g, session
 | 
			
		||||
from requests_oauthlib import OAuth1Session
 | 
			
		||||
 | 
			
		||||
from . import user_agent_headers
 | 
			
		||||
from .model import User
 | 
			
		||||
 | 
			
		||||
osm_api_base = "https://api.openstreetmap.org/api/0.6"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -67,11 +68,12 @@ def parse_userinfo_call(xml):
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_username():
 | 
			
		||||
def get_username() -> str | None:
 | 
			
		||||
    """Get username of current user."""
 | 
			
		||||
    if "user_id" not in session:
 | 
			
		||||
        return  # not authorized
 | 
			
		||||
        return None  # not authorized
 | 
			
		||||
 | 
			
		||||
    user_id = session["user_id"]
 | 
			
		||||
 | 
			
		||||
    user = User.query.get(user_id)
 | 
			
		||||
    return user.username
 | 
			
		||||
    return typing.cast(str, user.username)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,8 @@
 | 
			
		|||
"""Wikidata API."""
 | 
			
		||||
 | 
			
		||||
import json
 | 
			
		||||
import typing
 | 
			
		||||
from typing import Any, cast
 | 
			
		||||
from typing import cast
 | 
			
		||||
 | 
			
		||||
import requests
 | 
			
		||||
import simplejson.errors
 | 
			
		||||
| 
						 | 
				
			
			@ -9,7 +11,26 @@ from . import CallParams, user_agent_headers
 | 
			
		|||
 | 
			
		||||
wd_api_url = "https://www.wikidata.org/w/api.php"
 | 
			
		||||
 | 
			
		||||
EntityType = dict[str, Any]
 | 
			
		||||
Claims = dict[str, list[dict[str, typing.Any]]]
 | 
			
		||||
Sitelinks = dict[str, dict[str, typing.Any]]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EntityType(typing.TypedDict, total=False):
 | 
			
		||||
    """Wikidata Entity."""
 | 
			
		||||
 | 
			
		||||
    id: str
 | 
			
		||||
    ns: str
 | 
			
		||||
    type: str
 | 
			
		||||
    pageid: int
 | 
			
		||||
    title: str
 | 
			
		||||
    labels: dict[str, typing.Any]
 | 
			
		||||
    descriptions: dict[str, typing.Any]
 | 
			
		||||
    claims: Claims
 | 
			
		||||
    lastrevid: int
 | 
			
		||||
    sitelinks: Sitelinks
 | 
			
		||||
    modified: str
 | 
			
		||||
    redirects: dict[str, typing.Any]
 | 
			
		||||
    aliases: dict[str, list[dict[str, typing.Any]]]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def api_get(params: CallParams) -> requests.Response:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										13
									
								
								requirements.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								requirements.txt
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
flask
 | 
			
		||||
-e git+https://github.com/maxcountryman/flask-login.git#egg=Flask-Login
 | 
			
		||||
GeoIP
 | 
			
		||||
lxml
 | 
			
		||||
maxminddb
 | 
			
		||||
requests
 | 
			
		||||
sqlalchemy
 | 
			
		||||
requests_oauthlib
 | 
			
		||||
geoalchemy2
 | 
			
		||||
simplejson
 | 
			
		||||
user_agents
 | 
			
		||||
num2words
 | 
			
		||||
psycopg2
 | 
			
		||||
		Loading…
	
		Reference in a new issue