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
239 changes: 101 additions & 138 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,138 +1,101 @@
"""Web app that serves proselint's API."""

from flask import Flask, request, jsonify, make_response, Response
from flask_cors import CORS, cross_origin
from flask_limiter import Limiter
from functools import wraps
from urllib.parse import unquote
import hashlib
import proselint
from rq import Queue
from worker import conn


app = Flask(__name__)
cors = CORS(app)
app.config['CORS_HEADERS'] = "Origin, X-Requested-With, Content-Type, Accept"
limiter = Limiter(app)

q = Queue(connection=conn)


def worker_function(text):
"""Lint the text using a worker dyno."""
return proselint.tools.lint(text)


@app.errorhandler(429)
def ratelimit_handler(e):
"""Inform user that the rate limit has been exceeded."""
return make_response(
jsonify(status="error", message="Rate limit exceeded."), 429)


def check_auth(username, password):
"""Check if a username / password combination is valid."""
legal_hashes = [
"15a7fdade5fa58d38c6d400770e5c0e948fbc03ba365b704a6d205687738ae46",
"057b24043181523e3c3717071953c575bd13862517a8ce228601582a9cbd9dae",
"c8d79ae7d388b6da21cb982a819065b18941925179f88041b87de2be9bcde79c",
"bb7082271060c91122de8a3bbac5c7d6dcfe1a02902d57b27422f0062f602f72",
"90e4d9e4ec680ff40ce00e712b24a67659539e06d50b538ed4a3d960b4f3bda5",
"9a9a241b05eeaa0ca2b4323c5a756851c9cd15371a4d71a326749abc47062bf0",
"0643786903dab7cbb079796ea4b27a81fb38442381773759dd52ac8615eb6ab2",
"886078097635635c1450cf52ca0ec13a887ea4f8cd4b799fdedc650ec1f08781",
"d4c4d2d16c7fec2d0d60f0a110eb4fbd9f1bb463033298e01b3141a7e4ca10bc",
"83dfe687131d285a22c5379e19f4ebabcdfe8a19bade46a5cdcdc9e9c36b52a2",
"7c4000e5d6948055553eb84fc2188ccad068caa1b584303f88dc0c582a0ecd42",
"43c693fa32545b7d4106e337fe6edf7db92282795d5bdb80705ef8a0ac7e8030",
"ebb17f7f9050e3c1b18f84cbd6333178d575d4baf3aca6dfa0587cc2a48e02d0",
"ce910c4368092bf0886e59dc5df0b0ad11f40067b685505c2195463d32fa0418",
"86fc704debb389a73775e02f8f0423ffbbb787a1033e531b2e47d40f71ad5560",
"308af1914cb90aeb8913548cc37c9b55320875a2c0d2ecfe6afe1bfc02c64326",
"bd3486100f2bb29762100b93b1f1cd41655ab05767f78fb1fc4adfe040ebe953",
"29f56ee67dd218276984d723b6b105678faa1868a9644f0d9c49109c8322e1d8",
"704c3ddde0b5fd3c6971a6ef16991ddff3e241c170ed539094ee668861e01764",
"aaebc3ca0fe041a3a595170b8efda22308cd7d843510bf01263f05a1851cb173",
]
return hashlib.sha256(username + password).hexdigest() in legal_hashes


def authenticate():
"""Send a 401 response that enables basic auth."""
return Response(
'Could not verify your access level for that URL.\n'
'You have to login with proper credentials', 401,
{'WWW-Authenticate': 'Basic realm="Login Required"'})


def requires_auth(f):
"""Decorate methods that require authentication."""
@wraps(f)
def decorated(*args, **kwargs):
auth = request.authorization
if not auth or not check_auth(auth.username, auth.password):
return authenticate()
return f(*args, **kwargs)
return decorated


def rate():
"""Set rate limits for authenticated and nonauthenticated users."""
auth = request.authorization

if not auth or not check_auth(auth.username, auth.password):
return "60/minute"
else:
return "600/minute"


@app.route('/v0/', methods=['GET', 'POST'])
@limiter.limit(rate)
@cross_origin() # allow all origins all methods.
def lint():
"""Run linter on the provided text and return the results."""
if 'text' in request.values:
text = unquote(request.values['text'])
job = q.enqueue(worker_function, text)

return jsonify(job_id=job.id), 202

elif 'job_id' in request.values:
job = q.fetch_job(request.values['job_id'])

if not job:
return jsonify(
status="error",
message="No job with requested job_id."), 404

elif job.result is None:
return jsonify(
status="error",
message="Job is not yet ready."), 202

else:
errors = []
for i, e in enumerate(job.result):
app.logger.debug(e)
errors.append({
"check": e[0],
"message": e[1],
"line": e[2],
"column": e[3],
"start": e[4],
"end": e[5],
"extent": e[5] - e[4],
"severity": e[7],
"replacements": e[8],
"source_name": "",
"source_url": "",
})
return jsonify(
status="success",
data={"errors": errors})


if __name__ == '__main__':
app.debug = True
app.run()
"""A simple FastAPI app that serves a REST API for proselint."""

from os import getenv

from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from starlette.responses import JSONResponse

from proselint.checks import __register__
from proselint.registry import CheckRegistry
from proselint.tools import LintFile, LintResult

MAX_BODY_BYTES = int(getenv("MAX_BODY_BYTES", str(64 * 1024)))
RATELIMIT = getenv("RATELIMIT", "60/minute")


def _lint(input_text: str) -> list[LintResult]:
return LintFile(content=input_text, source="<api>").lint()


def _error(
status: int,
message: str,
) -> HTTPException:
return HTTPException(
status_code=status,
detail={
"status": "error",
"message": message,
},
)


app = FastAPI()
limiter = Limiter(key_func=get_remote_address)

app.state.limiter = limiter
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)

CheckRegistry().register_many(__register__)


@app.exception_handler(RateLimitExceeded)
def rate_limit_exceeded_handler(
_: Request,
exc: RateLimitExceeded,
) -> Response:
"""Middleware to handle exceeded ratelimits."""
return JSONResponse(
{
"status": "error",
"message": "rate limit exceeded",
"limit": str(exc.detail),
},
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
headers=getattr(exc, "headers", None),
)


@app.get("/v1/health")
async def health() -> dict[str, str]:
"""Endpoint to check if the service is alive."""
return {"status": "success", "message": "service is healthy"}


@app.post("/v1")
@limiter.limit(RATELIMIT)
async def index(request: Request) -> dict[str, object]:
"""Endpoint that lints text using proselint."""
body = await request.body()

if not body:
raise _error(
status.HTTP_400_BAD_REQUEST, "request body must contain text"
)

if len(body) > MAX_BODY_BYTES:
raise _error(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
f"request body must be at most {MAX_BODY_BYTES} bytes",
)

try:
text = body.decode("utf-8")
except UnicodeDecodeError:
raise _error(
status.HTTP_400_BAD_REQUEST, "request body must be valid utf-8 text"
) from None

return {
"status": "success",
"data": [r.into_dict() for r in _lint(text)],
}
41 changes: 39 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@
]
);

virtualenv = editablePythonSet.mkVirtualEnv "proselint-env" {proselint = ["test" "dev"];};
virtualenv = editablePythonSet.mkVirtualEnv "proselint-env" {proselint = ["test" "dev" "web"];};
in
pkgs.mkShell {
buildInputs = check.enabledPackages;
Expand Down Expand Up @@ -147,7 +147,11 @@
});

packages =
forAllSystems ({pythonSet, ...}: {
forAllSystems ({
pythonSet,
pkgs,
...
}: {
default = pythonSet.mkVirtualEnv "proselint-env" workspace.deps.default;

wheel =
Expand All @@ -161,6 +165,34 @@
}).overrideAttrs (old: {
env.uvBuildType = "sdist";
});

api = let
env =
pythonSet.mkVirtualEnv "proselint-api-env" {
proselint = ["web"];
};
in
pkgs.stdenv.mkDerivation {
name = "proselint-api";
src = ./.;

dontBuild = true;
dontConfigure = true;

installPhase = ''
mkdir -p $out/bin $out/share/proselint-api

cp $src/app.py $out/share/proselint-api

cat > $out/bin/proselint-api-run <<-EOF
#!${pkgs.bash}/bin/bash
cd $out/share/proselint-api
exec ${env}/bin/uvicorn app:app "\$@"
EOF

chmod +x $out/bin/proselint-api-run
'';
};
});

apps =
Expand All @@ -169,6 +201,11 @@
type = "app";
program = "${self.packages.${system}.default}/bin/proselint";
};

api = {
type = "app";
program = "${self.packages.${system}.api}/bin/proselint-api-run";
};
});

checks =
Expand Down
37 changes: 11 additions & 26 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "proselint"
description = "A linter for prose."
version = "0.16.0"
license = { file = "LICENSE.md" }
authors = [{ name = "Amperser Labs", email = "hello@amperser.com"}]
authors = [{ name = "Amperser Labs", email = "hello@amperser.com" }]
readme = "README.md"
classifiers = [
"Programming Language :: Python :: Implementation :: CPython",
Expand All @@ -15,10 +15,7 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
requires-python = ">=3.10"
dependencies = [
"google-re2>=1.1.20251105",
"google-re2-stubs>=0.1.1",
]
dependencies = ["google-re2>=1.1.20251105", "google-re2-stubs>=0.1.1"]

[project.urls]
Homepage = "https://github.com/amperser/proselint"
Expand All @@ -45,22 +42,8 @@ test = [
"pytest-cov>=6.2.1",
"rstr>=3.2.2",
]
dev = [
"ruff>=0.1.14",
"poethepoet>=0.34.0",
]
web = [
"APScheduler>=3.5.3",
"Flask-API>=1.0",
"Flask-Cors>=3.0.4",
"Flask>=1.1.4",
"Flask-Limiter>=1.0.1",
"gunicorn>=19.8.1",
"gmail @ git+https://github.com/charlierguo/gmail.git",
"redis>=2.10.6",
"requests>=2.19.1",
"rq>=0.12.0",
]
dev = ["ruff>=0.1.14", "poethepoet>=0.34.0"]
web = ["slowapi>=0.1.9", "fastapi>=0.124.4", "uvicorn[standard]>=0.38.0"]

[tool.pdm.build]
includes = ["proselint/"]
Expand Down Expand Up @@ -109,14 +92,16 @@ select = [
"YTT", # flake8-2020
]
ignore = [
"COM812", # trailing comma -> done by formatter
"D203", # no blank lines between classes and documentation
"D212", # consistent start level for multi-line edocumentation
"D415", # first line of docs should end with a period
"COM812", # trailing comma -> done by formatter
"D203", # no blank lines between classes and documentation
"D212", # consistent start level for multi-line edocumentation
"D415", # first line of docs should end with a period
]
preview = true

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "SLF001", "PLC2701"]
"proselint/checks/**" = ["RUF001"]
"proselint/registry/checks/engine.py" = ["D102"] # docs are inherited from base classes
"proselint/registry/checks/engine.py" = [
"D102",
] # docs are inherited from base classes
10 changes: 10 additions & 0 deletions typings/slowapi/__init__.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from typing import Any, Callable, TypeVar
from starlette.requests import Request

F = TypeVar("F", bound=Callable[..., Any])
KeyFunc = Callable[[Request], str]

class Limiter:
def __init__(self, key_func: KeyFunc) -> None: ...

def limit(self, limit: str) -> Callable[[F], F]: ...
Loading