Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion stats-api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ dependencies = [
[dependency-groups]
dev = [
"pytest>=8.4.2,<9",
"ruff>=0.15.20,<0.16",
"ruff>=0.15.20,<0.17",
"ty>=0.0.56",
]

Expand All @@ -29,6 +29,14 @@ default-groups = ["dev"]
[tool.uv.sources]
stats-entities = { path = "../stats-entities", editable = true }

[tool.ruff.lint]
ignore = [
"DTZ001", # datetime() without tzinfo
"DTZ005", # datetime.now() without tz
"DTZ007", # strptime() without %z
"B006", # mutable argument default
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
5 changes: 3 additions & 2 deletions stats-api/stats_api/app.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import os

from flask import Flask
from flask_cors import CORS
from sqlalchemy import URL
from werkzeug.exceptions import HTTPException

from stats_api.config.app import config_map
from stats_api.config.database import db
from stats_api.routes import stats_ui, stats_api
from stats_api.exception import handle_non_http_exception, handle_http_exception
from stats_api.exception import handle_http_exception, handle_non_http_exception
from stats_api.routes import stats_api, stats_ui


def create_app() -> Flask:
Expand Down
21 changes: 10 additions & 11 deletions stats-api/stats_api/config/app.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from typing import Type
from datetime import date
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import field_validator, Field

from pydantic import Field, field_validator
from pydantic_core.core_schema import ValidationInfo
from pydantic_settings import BaseSettings, SettingsConfigDict

from stats_api.config.urls import _URLS

Expand All @@ -29,12 +28,12 @@ class Query(BaseConfig):

class Database(BaseConfig):
drivername: str
username: Optional[str] = None
password: Optional[str] = None
host: Optional[str] = None
port: Optional[int] = None
username: str | None = None
password: str | None = None
host: str | None = None
port: int | None = None
database: str
query: Optional[Query] = None
query: Query | None = None


class Config(BaseConfig):
Expand All @@ -57,7 +56,7 @@ class Config(BaseConfig):
BASE_SERVER: str = SERVER_NAME
HELP_SERVER: str = "info.arxiv.org"
AUTH_SERVER: str = BASE_SERVER
URLS: Optional[dict[str, str]] = None
URLS: dict[str, str] | None = None

@field_validator("URLS")
def construct_urls(cls, v, info: ValidationInfo):
Expand Down Expand Up @@ -96,7 +95,7 @@ class ProdConfig(Config):
pass


config_map: dict[str, Type[Config]] = {
config_map: dict[str, type[Config]] = {
"TEST": TestConfig,
"DEV": DevConfig,
"PROD": ProdConfig,
Expand Down
2 changes: 0 additions & 2 deletions stats-api/stats_api/config/database.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
from flask_sqlalchemy import SQLAlchemy

from stats_entities import site_usage


db = SQLAlchemy(model_class=site_usage.SiteUsageBase)
2 changes: 1 addition & 1 deletion stats-api/stats_api/exception.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from werkzeug.exceptions import HTTPException, InternalServerError
from flask import render_template
from werkzeug.exceptions import HTTPException, InternalServerError


def handle_non_http_exception(e):
Expand Down
3 changes: 2 additions & 1 deletion stats-api/stats_api/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from pydantic import BaseModel, ConfigDict, Field
from datetime import date, datetime

from pydantic import BaseModel, ConfigDict, Field


class OrmBase(BaseModel):
model_config = ConfigDict(
Expand Down
16 changes: 8 additions & 8 deletions stats-api/stats_api/repository.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
from datetime import date, datetime
from typing import List
from sqlalchemy import func

from stats_api.config.database import db
from stats_api.models import MonthlyDownloads_, HourlyRequests_, MonthlySubmissions_
from sqlalchemy import func
from stats_entities.site_usage import (
HourlyDownloads,
HourlyRequests,
MonthlyDownloads,
MonthlySubmissions,
HourlyRequests,
)

from stats_api.config.database import db
from stats_api.models import HourlyRequests_, MonthlyDownloads_, MonthlySubmissions_


class SiteUsageRepository:
"""accepts and returns timezone naive, but utc date/datetime objects"""
Expand All @@ -26,7 +26,7 @@ def get_total_requests(start: datetime, end: datetime) -> int:
).scalar()

@staticmethod
def get_hourly_requests(start: datetime, end: datetime) -> List[HourlyRequests_]:
def get_hourly_requests(start: datetime, end: datetime) -> list[HourlyRequests_]:
results = (
db.session.execute(
db.select(HourlyRequests).where(
Expand All @@ -50,7 +50,7 @@ def get_total_submissions(date: date) -> int:
).scalar()

@staticmethod
def get_monthly_submissions() -> List[MonthlySubmissions_]:
def get_monthly_submissions() -> list[MonthlySubmissions_]:
results = db.session.execute(db.select(MonthlySubmissions)).scalars().all()

return [MonthlySubmissions_.model_validate(row) for row in results]
Expand Down Expand Up @@ -81,7 +81,7 @@ def get_total_downloads(month: date) -> int:
).scalar()

@staticmethod
def get_monthly_downloads(month: date) -> List[MonthlyDownloads_]:
def get_monthly_downloads(month: date) -> list[MonthlyDownloads_]:
"""month object should represent the first day of that month"""
results = db.session.execute(
db.select(MonthlyDownloads.month, MonthlyDownloads.downloads)
Expand Down
9 changes: 5 additions & 4 deletions stats-api/stats_api/routes.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
from http import HTTPStatus
from werkzeug.exceptions import BadRequest

from flask import (
Blueprint,
render_template,
make_response,
render_template,
request,
)
from flask.typing import ResponseReturnValue
from werkzeug.exceptions import BadRequest

from stats_api.service import StatsService
from stats_api.utils import (
set_fastly_headers,
get_arxiv_current_time,
url_param_to_date,
set_fastly_headers,
url_param_to_arxiv_datetime,
url_param_to_date,
)

stats_ui = Blueprint("stats_ui", __name__, url_prefix="/")
Expand Down
23 changes: 12 additions & 11 deletions stats-api/stats_api/service.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
from flask import current_app
from datetime import date, datetime, timezone
from datetime import UTC, date, datetime
from zoneinfo import ZoneInfo

from dateutil.relativedelta import relativedelta
from flask import current_app

from stats_api.repository import SiteUsageRepository
from stats_api.utils import (
get_utc_start_and_end_times,
format_as_csv,
utc_to_arxiv_local,
)
from stats_api.models import (
TodayPageData,
DownloadsPageData,
SubmissionsPageData,
HourlyRequests_,
MonthlyDownloads_,
SubmissionsPageData,
TodayPageData,
)
from stats_api.repository import SiteUsageRepository
from stats_api.utils import (
format_as_csv,
get_utc_start_and_end_times,
utc_to_arxiv_local,
)


Expand Down Expand Up @@ -60,7 +61,7 @@ def get_submissions_page_data(date: date) -> SubmissionsPageData:
@staticmethod
def get_downloads_page_data() -> DownloadsPageData:
latest_hour = SiteUsageRepository.get_latest_hour_for_downloads()
arxiv_latest_hour = latest_hour.replace(tzinfo=timezone.utc).astimezone(
arxiv_latest_hour = latest_hour.replace(tzinfo=UTC).astimezone(
ZoneInfo(current_app.config["ARXIV_TIMEZONE"])
)

Expand Down
23 changes: 12 additions & 11 deletions stats-api/stats_api/utils.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from flask import current_app
import io
import csv
from pydantic import BaseModel
from typing import List, Tuple, Callable, Sequence
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
import io
from collections.abc import Callable, Sequence
from datetime import UTC, date, datetime
from functools import wraps
from zoneinfo import ZoneInfo

from flask import current_app
from pydantic import BaseModel


def url_param_to_date(param: str) -> date:
Expand All @@ -20,7 +21,7 @@ def url_param_to_arxiv_datetime(param: str) -> datetime:
)


def set_fastly_headers(keys: List[str] = ["stats"]):
def set_fastly_headers(keys: list[str] = ["stats"]):
def decorator(function: Callable) -> Callable:
@wraps(function)
def decorated_function(*args, **kwargs):
Expand All @@ -41,7 +42,7 @@ def get_arxiv_current_time() -> datetime:
return datetime.now(tz=ZoneInfo(current_app.config["ARXIV_TIMEZONE"]))


def get_utc_start_and_end_times(date: date) -> Tuple[datetime, datetime]:
def get_utc_start_and_end_times(date: date) -> tuple[datetime, datetime]:
"""take a non-aware date object, assume arxiv local time,
return start and end datetimes representing the beginning of the
first and last hour of that day, utc
Expand All @@ -52,14 +53,14 @@ def get_utc_start_and_end_times(date: date) -> Tuple[datetime, datetime]:
date.day,
0,
tzinfo=ZoneInfo(current_app.config["ARXIV_TIMEZONE"]),
).astimezone(timezone.utc)
).astimezone(UTC)
end = datetime(
date.year,
date.month,
date.day,
23,
tzinfo=ZoneInfo(current_app.config["ARXIV_TIMEZONE"]),
).astimezone(timezone.utc)
).astimezone(UTC)
return start, end


Expand All @@ -74,6 +75,6 @@ def format_as_csv(models: Sequence[BaseModel]) -> str:


def utc_to_arxiv_local(dt: datetime) -> datetime:
return dt.replace(tzinfo=timezone.utc).astimezone(
return dt.replace(tzinfo=UTC).astimezone(
ZoneInfo(current_app.config["ARXIV_TIMEZONE"])
)
5 changes: 3 additions & 2 deletions stats-api/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import pytest

from stats_api.app import create_app
from tests.data.site_usage import (
mock_hourly_requests,
mock_monthly_submissions,
mock_hourly_downloads,
mock_hourly_requests,
mock_monthly_downloads,
mock_monthly_submissions,
)


Expand Down
5 changes: 3 additions & 2 deletions stats-api/tests/data/site_usage.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from datetime import date, datetime

from stats_entities.site_usage import (
HourlyRequests,
MonthlySubmissions,
HourlyDownloads,
HourlyRequests,
MonthlyDownloads,
MonthlySubmissions,
)

"""
Expand Down
10 changes: 5 additions & 5 deletions stats-api/tests/test_repository.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from datetime import date, datetime, timezone
from datetime import UTC, date, datetime

from stats_api.repository import SiteUsageRepository


def test_get_total_requests(app):
with app.app_context():
start = datetime(2025, 11, 10, 10, tzinfo=timezone.utc)
end = datetime(2025, 11, 12, 0, tzinfo=timezone.utc)
start = datetime(2025, 11, 10, 10, tzinfo=UTC)
end = datetime(2025, 11, 12, 0, tzinfo=UTC)

result = SiteUsageRepository.get_total_requests(start, end)

Expand All @@ -15,8 +15,8 @@ def test_get_total_requests(app):

def test_get_hourly_requests(app):
with app.app_context():
start = datetime(2025, 11, 10, 10, tzinfo=timezone.utc)
end = datetime(2025, 11, 11, 4, tzinfo=timezone.utc)
start = datetime(2025, 11, 10, 10, tzinfo=UTC)
end = datetime(2025, 11, 11, 4, tzinfo=UTC)

result = SiteUsageRepository.get_hourly_requests(start, end)

Expand Down
4 changes: 2 additions & 2 deletions stats-api/tests/test_routes.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from unittest.mock import patch
from datetime import date, datetime
from http import HTTPStatus
from datetime import datetime, date
from unittest.mock import patch

from stats_api.models import TodayPageData

Expand Down
4 changes: 2 additions & 2 deletions stats-api/tests/test_service.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from unittest.mock import patch
from datetime import date, datetime
from unittest.mock import patch

from stats_api.service import StatsService
from stats_api.models import MonthlyDownloads_
from stats_api.service import StatsService


@patch("stats_api.service.SiteUsageRepository")
Expand Down
Loading
Loading