Add CLI script to gridify all cities

- reorganize `urban_meal_delivery.console` into a sub-package
- move `tests.db.conftest` fixtures into `tests.conftest`
  => some integration tests regarding CLI scripts need a database
- add `urban_meal_delivery.console.decorators.db_revision` decorator
  to ensure the database is at a certain state before a CLI script runs
- refactor the `urban_meal_delivery.db.grids.Grid.gridify()` constructor:
  - bug fix: even empty `Pixel`s end up in the database temporarily
    => create `Pixel` objects only if an `Address` is to be assigned
       to it
  - streamline code and docstring
  - add further test cases
This commit is contained in:
Alexander Hess 2021-01-06 16:17:05 +01:00
commit 54ff377579
Signed by: alexander
GPG key ID: 344EA5AB10D868E0
15 changed files with 372 additions and 160 deletions

View file

@ -32,6 +32,8 @@ class Config:
# time horizon, we treat it as an ad-hoc order.
QUASI_AD_HOC_LIMIT = datetime.timedelta(minutes=45)
GRID_SIDE_LENGTHS = [707, 1000, 1414]
DATABASE_URI = os.getenv('DATABASE_URI')
# The PostgreSQL schema that holds the tables with the original data.

View file

@ -0,0 +1,9 @@
"""Provide CLI scripts for the project."""
from urban_meal_delivery.console import gridify
from urban_meal_delivery.console import main
cli = main.entry_point
cli.add_command(gridify.gridify)

View file

@ -0,0 +1,37 @@
"""Utils for the CLI scripts."""
import functools
import os
import subprocess # noqa:S404
import sys
from typing import Any, Callable
import click
def db_revision(rev: str) -> Callable: # pragma: no cover -> easy to check visually
"""A decorator ensuring the database is at a given revision."""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def ensure(*args: Any, **kwargs: Any) -> Any: # noqa:WPS430
"""Do not execute the `func` if the revision does not match."""
if not os.getenv('TESTING'):
result = subprocess.run( # noqa:S603,S607
['alembic', 'current'],
capture_output=True,
check=False,
encoding='utf8',
)
if not result.stdout.startswith(rev):
click.echo(
click.style(f'Database is not at revision {rev}', fg='red'),
)
sys.exit(1)
return func(*args, **kwargs)
return ensure
return decorator

View file

@ -0,0 +1,48 @@
"""CLI script to create pixel grids."""
import click
from urban_meal_delivery import config
from urban_meal_delivery import db
from urban_meal_delivery.console import decorators
@click.command()
@decorators.db_revision('888e352d7526')
def gridify() -> None: # pragma: no cover note:b1f68d24
"""Create grids for all cities.
This command creates grids with pixels of various
side lengths (specified in `urban_meal_delivery.config`).
Pixels are only generated if they contain at least one
(pickup or delivery) address.
All data are persisted to the database.
"""
cities = db.session.query(db.City).all()
click.echo(f'{len(cities)} cities retrieved from the database')
for city in cities:
click.echo(f'\nCreating grids for {city.name}')
for side_length in config.GRID_SIDE_LENGTHS:
click.echo(f'Creating grid with a side length of {side_length} meters')
grid = db.Grid.gridify(city=city, side_length=side_length)
db.session.add(grid)
click.echo(f' -> created {len(grid.pixels)} pixels')
# The number of assigned addresses is the same across different `side_length`s.
db.session.flush() # necessary for the query to work
n_assigned = (
db.session.query(db.AddressPixelAssociation)
.filter(db.AddressPixelAssociation.grid_id == grid.id)
.count()
)
click.echo(
f'=> assigned {n_assigned} out of {len(city.addresses)} addresses in {city.name}', # noqa:E501
)
db.session.commit()

View file

@ -1,14 +1,14 @@
"""Provide CLI scripts for the project."""
"""The entry point for all CLI scripts in the project."""
from typing import Any
import click
from click.core import Context
from click import core as cli_core
import urban_meal_delivery
def show_version(ctx: Context, _param: Any, value: bool) -> None:
def show_version(ctx: cli_core.Context, _param: Any, value: bool) -> None:
"""Show the package's version."""
# If --version / -V is NOT passed in,
# continue with the command.
@ -24,7 +24,7 @@ def show_version(ctx: Context, _param: Any, value: bool) -> None:
ctx.exit()
@click.command()
@click.group()
@click.option(
'--version',
'-V',
@ -33,5 +33,5 @@ def show_version(ctx: Context, _param: Any, value: bool) -> None:
is_eager=True,
expose_value=False,
)
def main() -> None:
def entry_point() -> None:
"""The urban-meal-delivery research project."""

View file

@ -10,15 +10,17 @@ That is the case on the CI server.
import os
import sqlalchemy as sa
from sqlalchemy import engine as engine_mod
from sqlalchemy import orm
import urban_meal_delivery
if os.getenv('TESTING'):
engine = None
connection = None
session = None
# Specify the types explicitly to make mypy happy.
engine: engine_mod.Engine = None
connection: engine_mod.Connection = None
session: orm.Session = None
else: # pragma: no cover
engine = sa.create_engine(urban_meal_delivery.config.DATABASE_URI)

View file

@ -57,8 +57,8 @@ class Grid(meta.Base):
def gridify(cls, city: db.City, side_length: int) -> db.Grid:
"""Create a fully populated `Grid` for a `city`.
The created `Grid` contains only the `Pixel`s for which
there is at least one `Address` in it.
The `Grid` contains only `Pixel`s that have at least one `Address`.
`Address` objects outside the `city`'s viewport are discarded.
Args:
city: city for which the grid is created
@ -69,28 +69,30 @@ class Grid(meta.Base):
"""
grid = cls(city=city, side_length=side_length)
# Create `Pixel` objects covering the entire `city`.
# Note: `+1` so that `city.northeast` corner is on the grid.
possible_pixels = [
db.Pixel(n_x=n_x, n_y=n_y)
for n_x in range((city.total_x // side_length) + 1)
for n_y in range((city.total_y // side_length) + 1)
]
# For convenient lookup by `.n_x`-`.n_y` coordinates.
pixel_map = {(pixel.n_x, pixel.n_y): pixel for pixel in possible_pixels}
# `Pixel`s grouped by `.n_x`-`.n_y` coordinates.
pixels = {}
for address in city.addresses:
# Determine which `pixel` the `address` belongs to.
n_x = address.x // side_length
n_y = address.y // side_length
pixel = pixel_map[n_x, n_y]
# Check if an `address` is not within the `city`'s viewport, ...
not_within_city_viewport = (
address.x < 0
or address.x > city.total_x
or address.y < 0
or address.y > city.total_y
)
# ... and, if so, the `address` does not belong to any `Pixel`.
if not_within_city_viewport:
continue
# Determine which `pixel` the `address` belongs to ...
n_x, n_y = address.x // side_length, address.y // side_length
# ... and create a new `Pixel` object if necessary.
if (n_x, n_y) not in pixels:
pixels[(n_x, n_y)] = db.Pixel(grid=grid, n_x=n_x, n_y=n_y)
pixel = pixels[(n_x, n_y)]
# Create an association between the `address` and `pixel`.
assoc = db.AddressPixelAssociation(address=address, pixel=pixel)
pixel.addresses.append(assoc)
# Only keep `pixel`s that contain at least one `Address`.
grid.pixels = [pixel for pixel in pixel_map.values() if pixel.addresses]
return grid