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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
STUDY_READER_ENVIRONMENT=development
STUDY_READER_LOG_LEVEL=INFO
STUDY_READER_PUBLISHED_CONTENT_DIR=content/published
20 changes: 20 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## Summary

<!-- What changed and why? -->

Closes #

## Evidence

- [ ] Tests added or updated before implementation
- [ ] `uv run ruff format --check .`
- [ ] `uv run ruff check .`
- [ ] `uv run mypy src`
- [ ] `uv run pytest`
- [ ] Relevant mobile and desktop behavior checked

## Security and content

- [ ] The public reader remains read-only
- [ ] No secrets or private network details are included
- [ ] Content is original and authoritative sources are linked
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: CI

on:
push:
pull_request:

permissions:
contents: read

jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Install Python
run: uv python install 3.12
- name: Install dependencies
run: uv sync --locked --group dev
- name: Check formatting
run: uv run ruff format --check .
- name: Lint
run: uv run ruff check .
- name: Type check
run: uv run mypy src
- name: Test application and content contracts
run: uv run pytest
25 changes: 25 additions & 0 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Contributor Covenant Code of Conduct

## Our pledge

We pledge to make participation in this project a harassment-free experience
for everyone, regardless of age, body size, disability, ethnicity, sex
characteristics, gender identity and expression, experience level, education,
socioeconomic status, nationality, personal appearance, race, religion, or
sexual identity and orientation.

## Our standards

Examples of positive behavior include empathy, constructive feedback, respect
for differing viewpoints, and accepting responsibility. Harassment, personal
attacks, trolling, and publishing another person's private information are not
acceptable.

## Enforcement

Report unacceptable behavior privately through the repository owner's GitHub
profile. Project maintainers may remove or reject contributions and may ban
contributors whose behavior is inappropriate, threatening, or harmful.

This policy is adapted from the Contributor Covenant, version 2.1:
https://www.contributor-covenant.org/version/2/1/code_of_conduct/
36 changes: 36 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Contributing to Study Reader

Thank you for helping make certification study material easier to read.

## Workflow

1. Choose or open a GitHub issue before substantial work.
2. Discuss changes that alter scope, architecture, security boundaries, or
content policy before implementation.
3. Create a focused branch and include tests or content checks with the change.
4. Open a pull request that links the issue and explains verification evidence.
5. Wait for human approval before merging.

Pull requests SHOULD be small enough to review carefully. New behavior MUST be
covered by representative tests. A discovered failure SHOULD become a named
regression test before it is fixed.

## Content contributions

Study chapters MUST be original writing grounded in authoritative sources.
They MUST identify source URLs and MUST NOT copy Microsoft Learn pages wholesale.
Claims about current exam objectives or Microsoft Fabric behavior MUST be
checked against current official Microsoft documentation.

## Local checks

```shell
uv sync --group dev
uv run ruff format --check .
uv run ruff check .
uv run mypy src
uv run pytest
```

Do not commit secrets, private network details, generated caches, local browser
state, or unpublished review artifacts.
76 changes: 74 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,74 @@
# study-reader
A mobile-first, book-like reader for studying Microsoft certification material with source-grounded chapters and offline support.
# Study Reader

Study Reader is a mobile-first, book-like web reader for Microsoft certification
study material. The first book targets DP-700, but the content contracts are
intended to support other Microsoft exams without application changes.

The application will combine FastAPI, Jinja, HTMX, authored Markdown, and a
Progressive Web App shell. Published chapters remain grounded in Microsoft
Learn, cite exact source pages, and present original explanations instead of
reproducing Microsoft Learn pages.

## Project status

The project is in its first foundation milestone. The repository currently
provides separate public reader and private administration application
factories, environment-validated settings, automated boundary tests, and CI.
Reader features and DP-700 content will be added through the public roadmap.

## Architecture boundary

- `study_reader.reader` is the public, read-only application. It MUST NOT expose
content refresh, publishing, mutation, or arbitrary URL-fetching endpoints.
- `study_reader.admin` is the private administration application. It MUST be
reachable only through the owner's tailnet in deployment.
- Published content is a versioned, read-only input to the public process.
- Browser reading state remains local for the MVP.

## Local development

Prerequisites: Python 3.12 or newer and [uv](https://docs.astral.sh/uv/).

```shell
uv sync --group dev
uv run uvicorn study_reader.reader.app:create_app --factory --reload
```

The reader is then available at `http://127.0.0.1:8000`. To run the private
administration application on a separate port:

```shell
uv run uvicorn study_reader.admin.app:create_app --factory --port 8001
```

Run all local quality gates with:

```shell
uv run ruff format --check .
uv run ruff check .
uv run mypy src
uv run pytest
```

Configuration uses environment variables beginning with `STUDY_READER_`. See
`.env.example` for non-secret examples. Never commit credentials or private
tailnet details.

## Microsoft attribution

Microsoft, Microsoft Fabric, Microsoft Learn, OneLake, and related names are
trademarks of the Microsoft group of companies. This independent open-source
project is not affiliated with or endorsed by Microsoft. Microsoft owns its
learning material; this project links to authoritative sources and publishes
original study-oriented writing under the repository license.

## Contributing and security

Work is tracked through GitHub issues and reviewed pull requests. Read
[`CONTRIBUTING.md`](CONTRIBUTING.md) before contributing. Please report
vulnerabilities privately as described in [`SECURITY.md`](SECURITY.md).

## License

Copyright (c) 2026 Troy Scott. Source code and original project content are
available under the [MIT License](LICENSE), unless a file states otherwise.
19 changes: 19 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Security Policy

## Reporting a vulnerability

Please do not disclose suspected vulnerabilities in a public issue. Use
GitHub's private vulnerability reporting feature for this repository. Include
the affected route or component, reproduction steps, impact, and any suggested
mitigation.

## Supported versions

Until the first release, only the current `main` branch receives security fixes.

## Deployment boundary

The reader is designed to be public and read-only. Administration, source
retrieval, refresh, and publishing are private operations and MUST remain
tailnet-only. A deployment MUST pass the public-route boundary tests before
Tailscale Funnel is enabled.
50 changes: 50 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "study-reader"
version = "0.1.0"
description = "A mobile-first reader for source-grounded certification study material"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
authors = [{ name = "Troy Scott" }]
dependencies = [
"fastapi>=0.116,<1",
"jinja2>=3.1,<4",
"pydantic-settings>=2.10,<3",
"uvicorn[standard]>=0.35,<1",
]

[dependency-groups]
dev = [
"httpx2>=2.12,<3",
"mypy>=1.17,<2",
"pytest>=8.4,<9",
"pytest-cov>=6.2,<7",
"ruff>=0.12,<1",
]

[tool.hatch.build.targets.wheel]
packages = ["src/study_reader"]

[tool.pytest.ini_options]
addopts = "--strict-config --strict-markers --cov=study_reader --cov-report=term-missing --cov-fail-under=90"
testpaths = ["tests"]

[tool.ruff]
line-length = 88
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "RUF"]

[tool.mypy]
python_version = "3.12"
strict = true
packages = ["study_reader"]

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
1 change: 1 addition & 0 deletions src/study_reader/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Study Reader application package."""
1 change: 1 addition & 0 deletions src/study_reader/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Private administration application."""
19 changes: 19 additions & 0 deletions src/study_reader/admin/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Private administration application factory."""

from fastapi import FastAPI

from study_reader.admin.routes import router
from study_reader.config import Settings, get_settings
from study_reader.lifecycle import build_lifespan


def create_app(settings: Settings | None = None) -> FastAPI:
"""Create the private administration application."""

resolved_settings = settings or get_settings()
app = FastAPI(
title="Study Reader Administration",
lifespan=build_lifespan(resolved_settings),
)
app.include_router(router)
return app
20 changes: 20 additions & 0 deletions src/study_reader/admin/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Routes reserved for the private administration service."""

from fastapi import APIRouter, Request

router = APIRouter(prefix="/admin")


@router.get("/health")
async def health(request: Request) -> dict[str, str]:
"""Report private administration process readiness."""

status = "ok" if request.app.state.ready else "starting"
return {"service": "admin", "status": status}


@router.get("/")
async def dashboard() -> dict[str, str]:
"""Provide a minimal private foundation without enabling mutations yet."""

return {"service": "admin", "status": "foundation"}
30 changes: 30 additions & 0 deletions src/study_reader/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Environment-driven application configuration."""

from functools import lru_cache
from pathlib import Path
from typing import Literal

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
"""Validated settings shared by the reader and administration apps."""

model_config = SettingsConfigDict(
env_prefix="STUDY_READER_",
env_file=".env",
env_file_encoding="utf-8",
extra="forbid",
)

environment: Literal["development", "test", "production"] = "development"
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
published_content_dir: Path = Field(default=Path("content/published"))


@lru_cache
def get_settings() -> Settings:
"""Return one validated settings object per application process."""

return Settings()
25 changes: 25 additions & 0 deletions src/study_reader/lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Shared FastAPI lifecycle behavior."""

from collections.abc import AsyncIterator, Callable
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from datetime import UTC, datetime

from fastapi import FastAPI

from study_reader.config import Settings


def build_lifespan(
settings: Settings,
) -> Callable[[FastAPI], AbstractAsyncContextManager[None]]:
"""Create an application lifespan bound to validated settings."""

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.settings = settings
app.state.started_at = datetime.now(UTC)
app.state.ready = True
yield
app.state.ready = False

return lifespan
1 change: 1 addition & 0 deletions src/study_reader/reader/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Public, read-only reader application."""
19 changes: 19 additions & 0 deletions src/study_reader/reader/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Public reader application factory."""

from fastapi import FastAPI

from study_reader.config import Settings, get_settings
from study_reader.lifecycle import build_lifespan
from study_reader.reader.routes import router


def create_app(settings: Settings | None = None) -> FastAPI:
"""Create the public read-only reader application."""

resolved_settings = settings or get_settings()
app = FastAPI(
title="Study Reader",
lifespan=build_lifespan(resolved_settings),
)
app.include_router(router)
return app
Loading
Loading