Skip to content

Commit

Permalink
Merge pull request #3 from lsst-sqre/tickets/DM-43967
Browse files Browse the repository at this point in the history
DM-43967: Add a realistic demo app with Astroplan domain
  • Loading branch information
jonathansick authored Apr 30, 2024
2 parents b10c61f + 287921f commit c7a07a8
Show file tree
Hide file tree
Showing 32 changed files with 2,726 additions and 295 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
with:
python-version: ${{ matrix.python }}
tox-envs: "py,coverage-report,typing"
tox-requirements: "requirements/tox.txt"

build:
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/periodic-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ jobs:
with:
python-version: ${{ matrix.python }}
tox-envs: "lint,typing,py"
tox-requirements: "requirements/tox.txt"
use-cache: false

- name: Report status
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ repos:
- id: trailing-whitespace

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.7
rev: v0.4.2
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,12 @@ Dependencies are updated to the latest available version during each release, an
Find changes for the upcoming release in the project's [changelog.d directory](https://github.com/lsst-sqre/fastapi-bootcamp/tree/main/changelog.d/).

<!-- scriv-insert-here -->

<a id='changelog-1.0.0'></a>
## 1.0.0 (2024-04-30)

### New features

- Add examples of FastAPI path operation functions to the external router.

- Add `/fastapi-bootcamp/astroplan` router with a basic API for observational sites and computing the observability of targets from those sites. This API is build around [astroplan](https://astroplan.readthedocs.io/en/stable/). We're including it in this app to demonstrate the service architecture that we prefer in SQuaRE, where the application's domain is isolated from concerns of the web API and even storage and other types of external adapters.
11 changes: 8 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.PHONY: help
help:
@echo "Make targets for fastapi-bootcamp"
@echo "Make targets for example"
@echo "make init - Set up dev environment"
@echo "make run - Start a local development instance"
@echo "make update - Update pinned dependencies and run make init"
Expand All @@ -10,10 +10,11 @@ help:
.PHONY: init
init:
pip install --upgrade uv
uv pip install --upgrade pre-commit tox
uv pip install -r requirements/main.txt -r requirements/dev.txt \
-r requirements/tox.txt
uv pip install --editable .
uv pip install -r requirements/main.txt -r requirements/dev.txt
rm -rf .tox
uv pip install --upgrade pre-commit
pre-commit install

.PHONY: run
Expand All @@ -32,6 +33,8 @@ update-deps:
--output-file requirements/main.txt requirements/main.in
uv pip compile --upgrade --generate-hashes \
--output-file requirements/dev.txt requirements/dev.in
uv pip compile --upgrade --generate-hashes \
--output-file requirements/tox.txt requirements/tox.in

# Useful for testing against a Git version of Safir.
.PHONY: update-deps-no-hashes
Expand All @@ -41,3 +44,5 @@ update-deps-no-hashes:
--output-file requirements/main.txt requirements/main.in
uv pip compile --upgrade \
--output-file requirements/dev.txt requirements/dev.in
uv pip compile --upgrade \
--output-file requirements/tox.txt requirements/tox.in
3 changes: 0 additions & 3 deletions changelog.d/20240429_155939_jsick_DM_43939.md

This file was deleted.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ ignore = [
"TID252", # if we're going to use relative imports, use them always
"TRY003", # good general advice but lint is way too aggressive
"TRY301", # sometimes raising exceptions inside try is the best flow
"UP040", # type keyword not supported by mypy yet

# The following settings should be disabled when using ruff format
# per https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
Expand Down
400 changes: 200 additions & 200 deletions requirements/dev.txt

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions requirements/main.in
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ starlette
uvicorn[standard]

# Other dependencies.
astroplan
pydantic
pydantic-settings
safir>=5
python-slugify
286 changes: 199 additions & 87 deletions requirements/main.txt

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions requirements/tox.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# -*- conf -*-
#
# Editable tox dependencies
# Add tox and its plugins here. These will be installed in the user's venv for
# local development and by CI when running tox actions.
#
# After editing, update requirements/dev.txt by running:
# make update-deps

-c main.txt
-c dev.txt

tox
tox-uv
75 changes: 75 additions & 0 deletions requirements/tox.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# This file was autogenerated by uv via the following command:
# uv pip compile --generate-hashes --output-file requirements/tox.txt requirements/tox.in
cachetools==5.3.3 \
--hash=sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945 \
--hash=sha256:ba29e2dfa0b8b556606f097407ed1aa62080ee108ab0dc5ec9d6a723a007d105
# via tox
chardet==5.2.0 \
--hash=sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7 \
--hash=sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970
# via tox
colorama==0.4.6 \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via tox
distlib==0.3.8 \
--hash=sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784 \
--hash=sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64
# via virtualenv
filelock==3.14.0 \
--hash=sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f \
--hash=sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a
# via
# tox
# virtualenv
packaging==24.0 \
--hash=sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5 \
--hash=sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9
# via
# pyproject-api
# tox
# tox-uv
platformdirs==4.2.1 \
--hash=sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf \
--hash=sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1
# via
# tox
# virtualenv
pluggy==1.5.0 \
--hash=sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1 \
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669
# via tox
pyproject-api==1.6.1 \
--hash=sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538 \
--hash=sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675
# via tox
tox==4.15.0 \
--hash=sha256:300055f335d855b2ab1b12c5802de7f62a36d4fd53f30bd2835f6a201dda46ea \
--hash=sha256:7a0beeef166fbe566f54f795b4906c31b428eddafc0102ac00d20998dd1933f6
# via tox-uv
tox-uv==1.8.0 \
--hash=sha256:35347683c52793c2827ba30f6254fbea774bd1beddc9136fb1203ff093bfdddf \
--hash=sha256:e36149f8721fe11668cad14b78d61d8fdedf6f6d2c56d2e00447b96a475b671b
uv==0.1.39 \
--hash=sha256:2333dd52e6734e0da6722bdd7b7257d0f8beeac89623c5cfc3888b4c56bc812e \
--hash=sha256:2ae930189742536f8178617c4ec05cb10271cb3886f6039abd36ee6ab511b160 \
--hash=sha256:2bda6686a9bb1370d7f53436d34f8ede0fa1b9877b5e152aedd9b22fc3cb33a9 \
--hash=sha256:3330bd7ab8a6160d815fdc36f48479edf6db8b58d39d20959555095ea7eb63c5 \
--hash=sha256:3365e0631a738a482d2379e565a230b135f7c5665394313829ccabf7c76c1362 \
--hash=sha256:388018659e5d73fdeb8ce13c1d812391ec981bf446ab86fb9c0e3d227f727da2 \
--hash=sha256:4c6ee1148f23aa5d6edf1a1106cc33c4aa57bdbfe8d4c5068c672105415d3b99 \
--hash=sha256:6b2acc907f7a1735dd9ffeb20d8c7aeeb86b1e5ba0a999e09433ad7f2789dc78 \
--hash=sha256:7848d703201e6867ae2c70d611e6ffd53d5e5adfc2c9abe89b6d021975e43e81 \
--hash=sha256:7ee426e0c5fa048cc44f3ac78e476121ef4365bb8bc9199d3cbffc372a80e55d \
--hash=sha256:88f5601ee957f9be2efc7a24d186f9d2641053806e107e0e42c5e522882c89e0 \
--hash=sha256:93217578e68a431df235173e390ad7df090499367cd7f5c811520fd4ea3d5047 \
--hash=sha256:c131dba5fe5079d9c5f06846649e35662901a9afd9b31de17714c63e042d91d2 \
--hash=sha256:c20b9023dac12ee518de79c91df313be7abb052440cb78f8ffb20dea81d3289e \
--hash=sha256:cd6d9629ab0e22ab2336b8d6363573ea5a7060ef82ff5d3e6da4b1b30522ef13 \
--hash=sha256:ce911087f56edc97a5792c17f682ed7611fedead0ea117f56bb6f3942eb3e7b3 \
--hash=sha256:fba96b3049aea5c1394cd360e5900e4af39829df48ed6fc55eba115c00c8195a
# via tox-uv
virtualenv==20.26.1 \
--hash=sha256:604bfdceaeece392802e6ae48e69cec49168b9c5f4a44e483963f9242eb0e78b \
--hash=sha256:7aa9982a728ae5892558bff6a2839c00b9ed145523ece2274fad6f414690ae75
# via tox
11 changes: 11 additions & 0 deletions src/fastapibootcamp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ class Config(BaseSettings):
LogLevel.INFO, title="Log level of the application's logger"
)

clear_iers_on_startup: bool = Field(
False,
title="Clear IERS cache on application startup",
description=(
"The IERS cache is used by Astropy and Astroplan. In development "
"this cache can be cleared on startup to exercise populating it. "
"Generally the cache is not pre-existing in production, so this "
"option is not needed."
),
)

model_config = SettingsConfigDict(
env_prefix="FASTAPI_BOOTCAMP_", case_sensitive=False
)
Expand Down
Empty file.
87 changes: 87 additions & 0 deletions src/fastapibootcamp/dependencies/requestcontext.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Request context FastAPI dependency."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Annotated, Any

from fastapi import Depends, Request, Response
from httpx import AsyncClient
from safir.dependencies.http_client import http_client_dependency
from safir.dependencies.logger import logger_dependency
from structlog.stdlib import BoundLogger

from ..factory import Factory

# This RequestContext dataclass is what will be provided by the
# context_dependency FastAPI dependency. This is a useful pattern that wraps
# up multiple FastAPI dependencies into one container, and is a useful
# place to put methods that work on those dependencies (e.g., like
# binding context to the logger.
#
# The RequestContext would hold the sqlalchemy Session for apps that use
# databases, or a Redis client, etc.


@dataclass
class RequestContext:
"""Holds the incoming request and its surrounding context.
The primary reason for the existence of this class is to allow the
functions involved in request processing to repeatedly rebind the request
logger to include more information, without having to pass both the
request and the logger separately to every function.
"""

request: Request
"""The incoming request."""

response: Response
"""The response to be returned.
The response can be modified to include additional headers or status codes.
"""

logger: BoundLogger
"""The request logger, rebound with discovered context."""

http_client: AsyncClient
"""The HTTPX async HTTP client."""

factory: Factory
"""The component factory."""

def rebind_logger(self, **values: Any) -> None:
"""Add the given values to the logging context.
Also updates the logging context stored in the request object in case
the request context later needs to be recreated from the request.
Parameters
----------
**values
Additional values that should be added to the logging context.
"""
self.logger = self.logger.bind(**values)


# This is the FastAPI dependency that provides the RequestContext. Notice
# how dependency function signatures look like FastAPI path operations.
# This context dependency takes the request and response arguemnts available
# to path operations, and also depends on other dependencies.


async def context_dependency(
request: Request,
response: Response,
logger: Annotated[BoundLogger, Depends(logger_dependency)],
http_client: Annotated[AsyncClient, Depends(http_client_dependency)],
) -> RequestContext:
"""Provide a RequestContext as a dependency."""
return RequestContext(
request=request,
response=response,
logger=logger,
http_client=http_client,
factory=Factory(logger=logger, http_client=http_client),
)
Empty file.
96 changes: 96 additions & 0 deletions src/fastapibootcamp/domain/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Models for the Astroplan-related app domain."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from typing import Any

from astroplan import Observer as AstroplanObserver
from astropy.coordinates import AltAz, Angle, SkyCoord
from astropy.time import Time

__all__ = ["Observer", "TargetObservability"]

# The domain layer is where your application's core business logic resides.
# In this demo, the domain is built around the Astroplan library and its
# Observer class. In fact, we subclass Astroplan's Observer to add some
# additional attributes that are useful for our application.
#
# Note that the domain layer doesn't have dependendencies on other layers like
# the storage or web API layers (unlike the service layer, which knows about
# the storage layer in abstract detail). The domain doesn't care that its
# in the middle of a FastAPI app. Every other layer of the app "cares" about
# the domain, though.


class Observer(AstroplanObserver):
"""The observer domain model."""

def __init__(
self,
observer_id: str,
local_timezone: str,
aliases: list[str] | None = None,
*args: Any,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)

self.observer_id = observer_id
self.aliases = list(aliases) if aliases else []
self.local_timezone = local_timezone


@dataclass(kw_only=True)
class TargetObservability:
"""The observability of a target for an observer."""

observer: Observer
target: SkyCoord
time: datetime
airmass: float | None
altaz: AltAz
is_above_horizon: bool
is_night: bool
moon_up: bool
moon_separation: Angle
moon_altaz: AltAz
moon_illumination: float

@classmethod
def compute(
cls,
observer: Observer,
target: SkyCoord,
time: datetime,
) -> TargetObservability:
"""Compute the observability of a target for an observer."""
astropy_time = Time(time)
is_up = observer.target_is_up(astropy_time, target)

altaz = target.transform_to(
AltAz(obstime=astropy_time, location=observer.location)
)
airmass = altaz.secz if is_up else None

is_night = observer.is_night(astropy_time)

moon_altaz = observer.moon_altaz(astropy_time)
moon_up = moon_altaz.alt > 0
moon_separation = altaz.separation(moon_altaz)
moon_illumination = observer.moon_illumination(astropy_time)

return cls(
observer=observer,
target=target,
time=time,
is_above_horizon=is_up,
airmass=airmass,
altaz=altaz,
is_night=is_night,
moon_up=moon_up,
moon_separation=moon_separation,
moon_altaz=moon_altaz,
moon_illumination=moon_illumination,
)
Loading

0 comments on commit c7a07a8

Please sign in to comment.