diff --git a/.flake8 b/.flake8 index d4ed837b..d327e0ad 100644 --- a/.flake8 +++ b/.flake8 @@ -1,22 +1,21 @@ # ******************************** # |docname| - Flake8 configuration # ******************************** -# Docs todo: provide nice hyperlinks to docs on these configuration settings, and perhaps some rationale (why 88 characters, etc.). +# To run, execute ``flake8`` from the root directory of the project. # -# To run, execute ``flake8 .`` from the root directory of the project. [flake8] +# Use `Black's default `_ of 88 charaters per line. max-line-length=88 -ignore=F821, +ignore= + # To be compatible with `Black's default`_. W503, - # space before ``:`` E203, - E501, + # Block comment should start with ``#``. See `pycodestyle error codes `_. We use this to comment code out. E265, - # too many ``#`` + # Too many leading '#' for block comment. Again, for commenting code out. E266, - E711, - # web2py needs to compare to ``== True`` or ``== False`` for queries. - E712 + # Line too long (82 > 79 characters). Flake8 complains about comment lines being too long, while Black allows tihs. Disable flake8's long line detection to avoid these spurious warnings. + E501, exclude = # Ignore Sphinx build output. diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 4257c46f..3496fbb1 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -30,12 +30,9 @@ jobs: run: | pip install -U pip pip install poetry + # Why are we doing an update here? Updates should be performed by committers, not automatically. poetry update poetry install - - name: flake8 quick test - run: | - # stop the build if there are Python syntax errors or undefined names - poetry run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - name: Run the Pre Commit Check Script run: | poetry run ./pre_commit_check.py diff --git a/README.md b/README.md deleted file mode 100644 index 218f51bd..00000000 --- a/README.md +++ /dev/null @@ -1,16 +0,0 @@ -New FastAPI-based Book Server for Runestone - -The goal of this project is to replace the parts of the web2py-based RunestoneServer. - -We would love development help on this. Please see our docs including information on contributing to this project [on readthedocs](https://bookserver.readthedocs.io/en/latest/) - - -Getting up and Running -====================== - -1. clone this repository -2. pip install poetry -3. run poetry install -4. run uvicorn bookserver.main:app --reload --port 8080 - -By default this will run with a small sqllite database for development. diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..dc7dd8df --- /dev/null +++ b/README.rst @@ -0,0 +1,34 @@ +******************************************* +New FastAPI-based Book Server for Runestone +******************************************* + +The goal of this project is to replace the parts of the web2py-based RunestoneServer. + +We would love development help on this. Please see our docs including information on contributing to this project `on readthedocs `_. + + +Quickstart +========== +First, install BookServer. + +Installation options +-------------------- +For development: + +- Clone this repository. +- `Install poetry `_. +- From the command line / terminal, change to the directory containing this repo then execute ``poetry install``. + +To install from PyPi: +- From the command line / terminal, execute ``python -m pip install -U BookServer`` or ``python3 -m pip install -U BookServer``. + +Building books +-------------- +- Check out the ``bookserver`` branch of the Ruestone Components repo and install it. +- Build a book with this branch. +- Copy it the `book path ` or update the book path to point to the location of a built book. +- Add the book's info to the database. + +Running the server +------------------ +From the command line / terminal, execute ``poetry run uvicorn bookserver.main:app --reload --port 8080``. If running in development mode, this must be executed from the directory containing the repo. diff --git a/alembic/env.py b/alembic/env.py index 394dad0f..09b66afa 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,6 +1,6 @@ -# ******************************** -# |docname|- Alembic configuration -# ******************************** +# ********************************* +# |docname| - Alembic configuration +# ********************************* # :index:`docs to write`: Better description... # # Imports diff --git a/bookserver/__init__.py b/bookserver/__init__.py index 1b197977..e69de29b 100644 --- a/bookserver/__init__.py +++ b/bookserver/__init__.py @@ -1,3 +0,0 @@ -# ******************************************** -# |docname| - Declare this directory a package -# ******************************************** diff --git a/bookserver/__main__.py b/bookserver/__main__.py new file mode 100644 index 00000000..40227452 --- /dev/null +++ b/bookserver/__main__.py @@ -0,0 +1,10 @@ +# ***************************************************** +# |docname| - Provide a simple method to run the server +# ***************************************************** +# From the terminal / command line, execute ``python -m bookserver``, which causes this script to run. + +if __name__ == "__main__": + import uvicorn + + # See https://www.uvicorn.org/deployment/#running-programmatically. + uvicorn.run("bookserver.main:app", port=8080) diff --git a/bookserver/applogger.py b/bookserver/applogger.py index 647bb732..1ec00a9d 100644 --- a/bookserver/applogger.py +++ b/bookserver/applogger.py @@ -11,7 +11,6 @@ import logging import sys -# # Third-party imports # ------------------- # None. diff --git a/bookserver/config.py b/bookserver/config.py index 898debf9..4e076987 100644 --- a/bookserver/config.py +++ b/bookserver/config.py @@ -37,25 +37,24 @@ class Settings(BaseSettings): google_ga: str = "" - # Either ``development``, ``production``, or ``test``, per `this code `. - config: str = "development" # production or test + # Either ``development``, ``production``, or ``test``, per `this code `. TODO: Use an Enum for this instead! (Will that work with env vars?) + config: str = "development" - # `Database setup `. + # `Database setup `. It must be an async connection; for example: + # + # - ``sqlite+aiosqlite:///./runestone.db`` + # - ``postgresql+asyncpg://postgres:bully@localhost/runestone`` prod_dburl: str = "sqlite+aiosqlite:///./runestone.db" dev_dburl: str = "sqlite+aiosqlite:///./runestone_dev.db" test_dburl: str = "sqlite+aiosqlite:///./runestone_test.db" - # Configure ads. + # Configure ads. TODO: Link to the place in the Runestone Components where this is used. adsenseid: str = "" num_banners: int = 0 serve_ad: bool = False - # :index:`docs to write`: **What's this?** - library_path: str = "/Users/bmiller/Runestone" - dbserver: str = "sqlite" - - # Specify the directory to serve books from. - book_path: Path = Path(__file__).parents[1] / "books" + # _`book_path`: specify the directory to serve books from. + book_path: Path = Path.home() / "Runestone/books" # This is the secret key used for generating the JWT token secret: str = "supersecret" diff --git a/bookserver/db.py b/bookserver/db.py index ec207b1f..ebd9d2c1 100644 --- a/bookserver/db.py +++ b/bookserver/db.py @@ -13,7 +13,7 @@ # # Third-party imports # ------------------- -# Enable asyncio for SQLAlchemy -- see `databases `_. +# Use asyncio for SQLAlchemy -- see `SQLAlchemy Asynchronous I/O (asyncio) `_. from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import create_async_engine @@ -25,11 +25,6 @@ # See `./config.py`. from bookserver.config import settings -# :index:`question`: does this belong in `./config.py`? Or does it just describe the format of a database URL for two databases? -# -## SQLALCHEMY_DATABASE_URL = "sqlite:///./bookserver.db" -## SQLALCHEMY_DATABASE_URL = "postgresql://user:password@postgresserver/db" - # .. _setting.dev_dburl: if settings.config == "development": DATABASE_URL = settings.dev_dburl @@ -40,11 +35,12 @@ else: assert False -if settings.dbserver == "sqlite": +if DATABASE_URL.startswith("sqlite"): connect_args = {"check_same_thread": False} else: connect_args = {} +# TODO: Remove the ``echo=True`` when done debugging. engine = create_async_engine(DATABASE_URL, connect_args=connect_args, echo=True) # This creates the SessionLocal class. An actual session is an instance of this class. async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) diff --git a/bookserver/internal/utils.py b/bookserver/internal/utils.py index 84734959..1b46102c 100644 --- a/bookserver/internal/utils.py +++ b/bookserver/internal/utils.py @@ -31,8 +31,8 @@ def canonicalize_tz(tstring: str) -> str: """ x = re.search(r"\((.*)\)", tstring) if x: - x = x.group(1) - y = x.split() + z = x.group(1) + y = z.split() if len(y) == 1: return tstring else: diff --git a/bookserver/main.py b/bookserver/main.py index 2426b32f..23a81196 100644 --- a/bookserver/main.py +++ b/bookserver/main.py @@ -25,6 +25,7 @@ from .routers import rslogging from .db import init_models from .session import auth_manager +from .config import settings from bookserver.applogger import rslogger # FastAPI setup @@ -33,6 +34,7 @@ # Base.metadata.create_all() app = FastAPI() +print(f"Serving books from {settings.book_path}.\n") # Routing # ------- @@ -54,9 +56,9 @@ async def startup(): await init_models() -# @app.on_event("shutdown") -# async def shutdown(): -# await database.disconnect() +## @app.on_event("shutdown") +## async def shutdown(): +## await database.disconnect() # this is just a simple example of adding a middleware @@ -101,9 +103,3 @@ def auth_exception_handler(request: Request, exc: NotAuthenticatedException): Redirect the user to the login page if not logged in """ return RedirectResponse(url="/auth/login") - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run(app, host="0.0.0.0", port="8080") diff --git a/bookserver/models.py b/bookserver/models.py index 9e9976c2..78ad1c57 100644 --- a/bookserver/models.py +++ b/bookserver/models.py @@ -1,3 +1,4 @@ +# ***************************************** # |docname| - definition of database models # ***************************************** # In this file we define our SQLAlchemy data models. These get translated into relational database tables. @@ -41,7 +42,6 @@ # Local application imports # ------------------------- -# None. from .db import Base @@ -99,12 +99,11 @@ class IdMixin: # Useinfo # ------- -# This defines the useinfo table in the database. This table logs nearly every click -# generated by a student. It gets very large and needs a lot of indexes to keep Runestone +# This defines the useinfo table in the database. This table logs nearly every click +# generated by a student. It gets very large and needs a lot of indexes to keep Runestone # from bogging down. -# Useinfo -# ------- -# User info logged by the `hsblog endpoint`. See there for more info. +# +# User info logged by the `log_book_event endpoint`. See there for more info. class Useinfo(Base, IdMixin): __tablename__ = "useinfo" # _`timestamp`: when this entry was recorded by this webapp. @@ -318,11 +317,11 @@ class Courses(Base, IdMixin): downloads_enabled = Column(Web2PyBoolean) courselevel = Column(String) - # # Create ``child_courses`` which all refer to a single ``parent_course``: children's ``base_course`` matches a parent's ``course_name``. See `adjacency list relationships `_. - # child_courses = relationship( - # - # "Courses", backref=backref("parent_course", remote_side=[course_name]) - # ) + ## # Create ``child_courses`` which all refer to a single ``parent_course``: children's ``base_course`` matches a parent's ``course_name``. See `adjacency list relationships `_. + ## child_courses = relationship( + ## + ## "Courses", backref=backref("parent_course", remote_side=[course_name]) + ## ) # Define a default query: the username if provided a string. Otherwise, automatically fall back to the id. @classmethod diff --git a/bookserver/routers/assessment.py b/bookserver/routers/assessment.py index 1a6ac788..fd624b2a 100644 --- a/bookserver/routers/assessment.py +++ b/bookserver/routers/assessment.py @@ -15,19 +15,16 @@ # # Standard library # ---------------- -import datetime +# None. # Third-party imports # ------------------- -# :index:`todo`: **Lots of unused imports here...** -from dateutil.parser import parse from fastapi import APIRouter # Local application imports # ------------------------- from ..applogger import rslogger from ..crud import create_useinfo_entry, fetch_last_answer_table_entry # noqa F401 -from ..internal.utils import canonicalize_tz from ..schemas import AssessmentRequest, LogItem, LogItemIncoming # noqa F401 # Routing @@ -50,19 +47,6 @@ async def get_assessment_results(request_data: AssessmentRequest): # else: # sid = auth.user.username - # :index:`todo`: **This whole thing is messy - get the deadline from the assignment in the db.** - if request_data.deadline: - try: - deadline = parse(canonicalize_tz(request_data.deadline)) - tzoff = session.timezoneoffset if session.timezoneoffset else 0 - deadline = deadline + datetime.timedelta(hours=float(tzoff)) - deadline = deadline.replace(tzinfo=None) - except Exception: - rslogger.error(f"Bad Timezone - {request_data.deadline}") - deadline = datetime.datetime.utcnow() - else: - request_data.deadline = datetime.datetime.utcnow() - # Identify the correct event and query the database so we can load it from the server row = await fetch_last_answer_table_entry(request_data) diff --git a/bookserver/routers/auth.py b/bookserver/routers/auth.py index 1a264a4b..90c737c1 100644 --- a/bookserver/routers/auth.py +++ b/bookserver/routers/auth.py @@ -19,7 +19,6 @@ # # Third-party imports # ------------------- -# :index:`todo`: **Lots of unused imports...can we deletet these?** from fastapi import APIRouter, Depends, Request, Response # noqa F401 from fastapi_login.exceptions import InvalidCredentialsException from fastapi.security import OAuth2PasswordRequestForm @@ -46,6 +45,10 @@ templates = Jinja2Templates(directory=f"bookserver/templates{router.prefix}") +# .. _login: +# +# login +# ----- @router.get("/login", response_class=HTMLResponse) def login_form(request: Request): return templates.TemplateResponse("login.html", {"request": request}) diff --git a/bookserver/routers/books.py b/bookserver/routers/books.py index 80fda6c8..21efea2b 100644 --- a/bookserver/routers/books.py +++ b/bookserver/routers/books.py @@ -14,8 +14,6 @@ # Third-party imports # ------------------- -# :index:`todo`: **Lots of unused imports here...can we remove them?*** - from fastapi import APIRouter, Depends, Request, HTTPException # noqa F401 from fastapi.responses import FileResponse, HTMLResponse from fastapi.templating import Jinja2Templates @@ -118,6 +116,7 @@ async def serve_page( course_name=course, base_course=course, user_id=user.username, + # TODO user_email="bonelake@mac.com", downloads_enabled="false", allow_pairs="false", diff --git a/bookserver/routers/rslogging.py b/bookserver/routers/rslogging.py index 5458d356..5e7935b6 100644 --- a/bookserver/routers/rslogging.py +++ b/bookserver/routers/rslogging.py @@ -14,7 +14,6 @@ # # Third-party imports # ------------------- -# :index:`todo`: **Lots of unused imports...can we deletet these?** from fastapi import APIRouter, Depends # noqa F401 # Local application imports @@ -32,8 +31,12 @@ ) +# .. _log_book_event endpoint: +# +# log_book_event endpoint +# ----------------------- @router.post("/bookevent") -async def log_book_event(entry: LogItemIncoming): +async def log_book_event(entry: LogItem): """ This endpoint is called to log information for nearly every click that happens in the textbook. It uses the ``LogItem`` object to define the JSON payload it gets from a page of a book. diff --git a/bookserver/schemas.py b/bookserver/schemas.py index 36b26c13..ab081c44 100644 --- a/bookserver/schemas.py +++ b/bookserver/schemas.py @@ -10,20 +10,70 @@ # # Standard library # ---------------- -# For ``time`, ``date``, and ``timedelta``. -from datetime import datetime - -# For ``List``. -from typing import Optional +from datetime import datetime, timedelta +from dateutil.parser import parse +from typing import Container, Optional, Type, Dict, Tuple, Union, Any # Third-party imports # ------------------- -# See: https://pydantic-docs.helpmanual.io/usage/types/#datetime-types for field types -from pydantic import BaseModel +from pydantic import BaseModel, create_model, constr, validator, Field # Local application imports # ------------------------- -# None. +from . import models +from .internal.utils import canonicalize_tz + + +# Schema generation +# ================= +# This creates then returns a Pydantic schema from a SQLAlchemy Table or ORM class. +# +# This is copied from https://github.com/tiangolo/pydantic-sqlalchemy/blob/master/pydantic_sqlalchemy/main.py then lightly modified. +def sqlalchemy_to_pydantic( + # The SQLAlchemy model -- either a Table object or a class derived from a declarative base. + db_model: Type, + *, + # An optional Pydantic `model config `_ class to embed in the resulting schema. + config: Optional[Type] = None, + # The base class from which the Pydantic model will inherit. + base: Optional[Type] = None, + # SQLAlchemy fields to exclude from the resulting schema, provided as a sequence of field names. + exclude: Container[str] = [], +) -> Type[BaseModel]: + + # If provided an ORM model, get the underlying Table object. + db_model = getattr(db_model, "__table__", db_model) + + fields: Dict[str, Union[Tuple[str, Any], Type]] = {} + for column in db_model.columns: + # Determine the name of this column. + name = column.key + if name in exclude: + continue + + # Determine the Python type of the column. + python_type = column.type.python_type + if python_type == str and hasattr(column.type, "length"): + python_type = constr(max_length=column.type.length) + + # Determine the default value for the column. + default = None + if column.default is None and not column.nullable: + default = ... + + # Build the schema based on this info. + fields[name] = (python_type, default) + + # Optionally include special key word arguments. See `create_model `_. + if config: + fields["__config__"] = config + if base: + fields["__base__"] = base + pydantic_model = create_model(str(db_model.name), **fields) # type: ignore + return pydantic_model + + +Useinfo = sqlalchemy_to_pydantic(models.Useinfo.__table__) # Schemas @@ -35,7 +85,7 @@ class LogItemIncoming(BaseModel): to add additional constraints we can do so. """ - # :index:`TODO`: Is there any way to specify max string lengths? The database has fixed-length fields for some of these. + # FIXME: Use max lengths for strings based on the actual lengths from the database using `Pydantic constraints `_. Is there any way to query the database for these, instead of manually keeping them in sync? event: str act: str div_id: str @@ -57,11 +107,22 @@ class LogItem(LogItemIncoming): This may seem like overkill but it illustrates a point. The schema for the incoming log data will not contain a timestamp. We could make it optional there, but then that would imply that it is optional which it most certainly is not. We could add timestamp as part of a LogItemCreate class similar to how password is handled in the tutorial: https://fastapi.tiangolo.com/tutorial/sql-databases/ But there is no security reason to exclude timestamp. So I think this is a reasonable compromise. """ - timestamp: datetime + timestamp: datetime = Field(default_factory=datetime.utcnow) - class Config: - orm_mode = True - # this tells pydantic to try read anything with attributes we give it as a model + @validator("timestamp") + def str_to_datetime(cls, value: str) -> datetime: + # TODO: this code probably doesn't work. + try: + deadline = parse(canonicalize_tz(value)) + # TODO: session isn't defined. Here's a temporary fix + # tzoff = session.timezoneoffset if session.timezoneoffset else 0 + tzoff = 0 + deadline = deadline + timedelta(hours=float(tzoff)) + deadline = deadline.replace(tzinfo=None) + except Exception: + # TODO: can this enclose just the parse code? Or can an error be raised in other cases? + raise ValueError(f"Bad Timezone - {value}") + return deadline class AssessmentRequest(BaseModel): @@ -69,6 +130,7 @@ class AssessmentRequest(BaseModel): div_id: str event: str sid: Optional[str] = None + # See `Field with dynamic default value `_. deadline: Optional[str] = None diff --git a/bookserver/session.py b/bookserver/session.py index 9b1de05f..683af0b8 100644 --- a/bookserver/session.py +++ b/bookserver/session.py @@ -8,9 +8,22 @@ # OR in a header to be validated. If the token is valid then the user will be looked # up in the database using the ``load_user`` function in this file. # see `./routers/auth.py` for more detail. + +# Imports +# ======= +# These are listed in the order prescribed by `PEP 8`_. # -from bookserver.config import settings +# Standard library +# ---------------- +from typing import Optional + +# Third-party imports +# ------------------- from fastapi_login import LoginManager + +# Local application imports +# ------------------------- +from .config import settings from . import schemas from .crud import fetch_user from .applogger import rslogger @@ -21,7 +34,7 @@ @auth_manager.user_loader -async def load_user(user_id: str) -> schemas.User: +async def load_user(user_id: str) -> Optional[schemas.User]: """ fetch a user object from the database. This is designed to work with the original web2py auth_user schema but make it easier to migrate to a new @@ -31,6 +44,7 @@ async def load_user(user_id: str) -> schemas.User: user = await fetch_user(user_id) rslogger.debug(f"user = {str(user)}") if user: + # TODO: I don't understand -- why do this here? Are we validating an existing user object? return schemas.User( username=user.username, first_name=user.first_name, diff --git a/bookserver/templates/auth/login.html b/bookserver/templates/auth/login.html index d942eff6..cfd34c6d 100644 --- a/bookserver/templates/auth/login.html +++ b/bookserver/templates/auth/login.html @@ -1,3 +1,9 @@ + Login diff --git a/bookserver/toctree.rst b/bookserver/toctree.rst index 719a65c6..217bc90d 100644 --- a/bookserver/toctree.rst +++ b/bookserver/toctree.rst @@ -19,5 +19,6 @@ BookServer web application dependencies.py routers/toctree internal/toctree + templates/auth/login.html config.py - __init__.py + __main__.py diff --git a/conf.py b/conf.py index 071c626e..7b1e3119 100644 --- a/conf.py +++ b/conf.py @@ -27,11 +27,14 @@ # # Standard library # ---------------- -# None. +from pathlib import Path + # # Third-party imports # ------------------- import CodeChat.CodeToRest +from sphinx.application import Sphinx +from sphinx.config import Config # Local application imports # ------------------------- @@ -172,10 +175,11 @@ # Misc files. "Thumbs.db", ".DS_Store", - # Pytest files. + # Files excluded for obvious reasons. "**/.pytest_cache", ".pytest_cache", - # Poetry files. + "**/.mypy_cache", + ".mypy_cache", "poetry.lock", # **CodeChat notes:** # @@ -186,10 +190,10 @@ # The ``CodeToRestSphinx`` extension creates a file named # ``sphinx-enki-info.txt``, which should be ignored by Sphinx. "sphinx-enki-info.txt", + # TODO: Notes here. I assume this is produced by the coverage run in the test suite? A link to that point in the code would be nice. "test/htmlcov", + # `Alembic `_ stores auto-generated migration scripts `here `_. "alembic/versions", - "**/.mypy_cache", - "bookserver/templates", ] # `default_role `_: The @@ -327,3 +331,32 @@ ), ) } + + +# Exclude empty files matching the given glob from the build. +def exclude_empty_files( + # The Sphinx application object. + app_: Sphinx, + # A `glob pattern `_ specifying which files should be excluded if they are empty. + pattern: str, +): + # This returns a function which will be called by the `config-inited`_ event. + def excluder(app: Sphinx, config: Config): + # The path must start in the `srcdir `_. + root_path = Path(app.srcdir) + # This is slightly inefficient, since it doesn't use the existing excludes to avoid searching already-excluded values. + app.config.exclude_patterns += [ # type: ignore + # Paths must be relative to the srcdir. + x.relative_to(root_path).as_posix() + for x in root_path.glob(pattern) + if x.stat().st_size == 0 + ] + + # Connect this to the `config-inited `_ Sphinx event. + app_.connect("config-inited", excluder) + + +def setup(app): + # Exclude all empty Python files, since these add no value. (Typically, this will be ``__init__.py``.) + exclude_empty_files(app, "**/*.py") + return {"parallel_read_safe": True} diff --git a/index.rst b/index.rst index bf4ac3b5..720d0b33 100644 --- a/index.rst +++ b/index.rst @@ -1,22 +1,21 @@ ************************************** FastAPI Based BookServer documentation ************************************** -:index:`docs to write`: The BookServer is a next-generation server for the Runestone platform... +:index:`docs to write`: The BookServer is a next-generation server for the Runestone platform... See `README` for an overview and installation instructions. See the `genindex` for todo, fixme, etc. items. Web application =============== - .. toctree:: :maxdepth: 1 + README bookserver/toctree alembic/toctree - Development support =================== .. toctree:: @@ -27,6 +26,7 @@ Development support pyproject.toml .gitignore .flake8 + mypy.ini Documentation generation diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..95175ff6 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,48 @@ +; .. License +; +; Copyright (C) 2012-2020 Bryan A. Jones. +; +; This file is part of CodeChat. +; +; CodeChat is free software: you can redistribute it and/or modify it under +; the terms of the GNU General Public License as published by the Free +; Software Foundation, either version 3 of the License, or (at your option) +; any later version. +; +; CodeChat is distributed in the hope that it will be useful, but WITHOUT ANY +; WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +; FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +; details. +; +; You should have received a copy of the GNU General Public License along +; with CodeChat. If not, see . +; +; ****************************** +; |docname| - mypy configuration +; ****************************** +; To run, execute ``mypy`` from the directory containing this file. +; +; This section `must `_ be present. +[mypy] +# See `files `_. +files = . +exclude = (^_build/)|(^alembic/) + +; These libraries lack annotations. `Ignore missing imports `_. +[mypy-alembic.*] +ignore_missing_imports = True + +[mypy-CodeChat.*] +ignore_missing_imports = True + +[mypy-fastapi_login.*] +ignore_missing_imports = True + +[mypy-pydal.validators.*] +ignore_missing_imports = True + +[mypy-sqlalchemy.*] +ignore_missing_imports = True + +[mypy-uvicorn.*] +ignore_missing_imports = True diff --git a/pre_commit_check.py b/pre_commit_check.py index 0e711ad7..a8da0e80 100755 --- a/pre_commit_check.py +++ b/pre_commit_check.py @@ -34,6 +34,7 @@ def checks(): "black --check .", # Do this next -- it should be easy to fix most of these. "flake8 .", + "mypy", # Next, check the docs. Again, these only require fixes to comments, and should still be relatively easy to correct. # # Force a `full build `_: diff --git a/pyproject.toml b/pyproject.toml index dcdcbdde..0759f802 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ # ******************************** # # See https://python-poetry.org/docs/dependency-specification/ to get an understanding of -# how poetry does specifies dependencies +# how poetry does specifies dependencies. # [tool.poetry] name = "bookserver" @@ -11,13 +11,14 @@ version = "0.1.0" description = "A new Runestone Server Framework" authors = ["Brad Miller "] license = "MIT" -readme = "README.md" +readme = "README.rst" documentation = "https://bookserver.readthedocs.io/en/latest/" [tool.poetry.dependencies] python = "^3.7" fastapi = "^0.63.0" -uvicorn = "^0.13.1" +# Per the `uvicorn docs `_, install the standard (as opposed to minimal) uvicorn dependencies. +uvicorn = {extras = ["standard"], version = "^0.13.1"} Jinja2 = "^2.11.2" aiofiles = "^0.6.0" alembic = "^1.4.3" @@ -38,6 +39,7 @@ flake8 = "^3.8.4" CodeChat_Server = "^0.0.15" recommonmark = "^0.7.0" sphinx-rtd-theme = "^0.5.2" +mypy = "^0.812" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/test/__init__.py b/test/__init__.py index 1b197977..e69de29b 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -1,3 +0,0 @@ -# ******************************************** -# |docname| - Declare this directory a package -# ******************************************** diff --git a/test/test_rslogging.py b/test/test_rslogging.py index b9560af7..46a293a6 100644 --- a/test/test_rslogging.py +++ b/test/test_rslogging.py @@ -1,14 +1,31 @@ # ******************************** # |docname| - test the logging API # ******************************** - +# +# Imports +# ======= +# These are listed in the order prescribed by `PEP 8`_. +# +# Standard library +# ---------------- +# None. +# +# Third-party imports +# ------------------- from fastapi.testclient import TestClient -from bookserver.schemas import LogItemIncoming +from pydantic import ValidationError +import pytest + +# Local application imports +# ------------------------- +from bookserver.schemas import LogItemIncoming, Useinfo from bookserver.main import app from bookserver.schemas import AssessmentRequest from bookserver.applogger import rslogger +# Tests +# ===== def test_main(): with TestClient(app) as client: response = client.get("/") @@ -67,5 +84,11 @@ def test_add_mchoice(): ) assert response.status_code == 200 res = response.json() - assert res["correct"] == True + assert res["correct"] is True assert res["div_id"] == "test_mchoice_1" + + +def test_schema_generator(): + with pytest.raises(ValidationError): + # The sid Column has a max length of 512. This should fail validation. + Useinfo(sid="x" * 600, id="5") diff --git a/test/toctree.rst b/test/toctree.rst index 36516d7b..8c483edc 100644 --- a/test/toctree.rst +++ b/test/toctree.rst @@ -1,17 +1,15 @@ ************************** Testing for the BookServer ************************** -To run the tests, execute ``pytest`` from this subdirectory. FastAPI takes care of starting up a server! +To run the tests, execute ``poetry run pytest`` from this subdirectory. FastAPI takes care of starting up a server! .. toctree:: :maxdepth: 1 test_rslogging.py - __init__.py Continuous Integration ---------------------- - .. toctree:: :maxdepth: 1