alessandro trinca tornidor
commited on
Commit
·
c98ad9f
1
Parent(s):
c1d7a3e
feat: use structlog with asgi-correlation-id; force python < 4.0.0 in pyproject.toml
Browse files- app.py +36 -17
- my_ghost_writer/constants.py +13 -1
- my_ghost_writer/middlewares.py +66 -0
- my_ghost_writer/session_logger.py +143 -0
- poetry.lock +22 -3
- pyproject.toml +2 -1
- tests/__init__.py +2 -2
app.py
CHANGED
|
@@ -1,25 +1,41 @@
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
from dotenv import load_dotenv
|
|
|
|
|
|
|
| 4 |
from fastapi import FastAPI
|
|
|
|
| 5 |
from fastapi.responses import FileResponse, JSONResponse
|
| 6 |
from fastapi.staticfiles import StaticFiles
|
| 7 |
import uvicorn
|
| 8 |
|
| 9 |
-
from my_ghost_writer.constants import
|
| 10 |
from my_ghost_writer.type_hints import RequestTextFrequencyBody
|
| 11 |
|
| 12 |
|
| 13 |
load_dotenv()
|
| 14 |
-
DEBUG = os.getenv("DEBUG", "")
|
| 15 |
-
static_folder = root_folder / "static"
|
| 16 |
fastapi_title = "My Ghost Writer"
|
| 17 |
app = FastAPI(title=fastapi_title, version="1.0")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
@app.get("/health")
|
| 21 |
def health():
|
| 22 |
-
|
|
|
|
|
|
|
| 23 |
return "Still alive..."
|
| 24 |
|
| 25 |
|
|
@@ -31,13 +47,12 @@ def get_word_frequency(body: RequestTextFrequencyBody | str) -> JSONResponse:
|
|
| 31 |
from my_ghost_writer.text_parsers import get_words_tokens_and_indexes
|
| 32 |
|
| 33 |
t0 = datetime.now()
|
| 34 |
-
|
| 35 |
-
|
| 36 |
body = json.loads(body)
|
| 37 |
text = body["text"]
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
print(f"DEBUG: '{DEBUG}', length of text: {len(text)}.")
|
| 41 |
ps = PorterStemmer()
|
| 42 |
text_split_newline = text.split("\n")
|
| 43 |
row_words_tokens = []
|
|
@@ -47,28 +62,32 @@ def get_word_frequency(body: RequestTextFrequencyBody | str) -> JSONResponse:
|
|
| 47 |
row_offsets_tokens.append(WordPunctTokenizer().span_tokenize(row))
|
| 48 |
words_stems_dict = get_words_tokens_and_indexes(row_words_tokens, row_offsets_tokens, ps)
|
| 49 |
dumped = json.dumps(words_stems_dict)
|
| 50 |
-
|
| 51 |
-
print(f"dumped: {dumped} ...")
|
| 52 |
t1 = datetime.now()
|
| 53 |
duration = (t1 - t0).total_seconds()
|
| 54 |
n_total_rows = len(text_split_newline)
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
| 58 |
|
|
|
|
| 59 |
|
| 60 |
-
app.
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
@app.get("/")
|
| 64 |
@app.get("/static/")
|
| 65 |
def index() -> FileResponse:
|
| 66 |
-
return FileResponse(path=
|
| 67 |
|
| 68 |
|
| 69 |
if __name__ == "__main__":
|
| 70 |
try:
|
| 71 |
-
uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=bool(
|
| 72 |
except Exception as ex:
|
| 73 |
print(f"fastapi/gradio application {fastapi_title}, exception:{ex}!")
|
|
|
|
| 74 |
raise ex
|
|
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
from asgi_correlation_id import CorrelationIdMiddleware
|
| 6 |
from fastapi import FastAPI
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from fastapi.responses import FileResponse, JSONResponse
|
| 9 |
from fastapi.staticfiles import StaticFiles
|
| 10 |
import uvicorn
|
| 11 |
|
| 12 |
+
from my_ghost_writer.constants import ALLOWED_ORIGIN_LIST, IS_TESTING, LOG_LEVEL, STATIC_FOLDER, app_logger
|
| 13 |
from my_ghost_writer.type_hints import RequestTextFrequencyBody
|
| 14 |
|
| 15 |
|
| 16 |
load_dotenv()
|
|
|
|
|
|
|
| 17 |
fastapi_title = "My Ghost Writer"
|
| 18 |
app = FastAPI(title=fastapi_title, version="1.0")
|
| 19 |
+
app.add_middleware(
|
| 20 |
+
CORSMiddleware,
|
| 21 |
+
allow_origins=ALLOWED_ORIGIN_LIST,
|
| 22 |
+
allow_credentials=True,
|
| 23 |
+
allow_methods=["GET", "POST"]
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@app.middleware("http")
|
| 28 |
+
async def request_middleware(request, call_next):
|
| 29 |
+
from my_ghost_writer.middlewares import logging_middleware
|
| 30 |
+
|
| 31 |
+
return await logging_middleware(request, call_next)
|
| 32 |
|
| 33 |
|
| 34 |
@app.get("/health")
|
| 35 |
def health():
|
| 36 |
+
from nltk import __version__ as nltk_version
|
| 37 |
+
from fastapi import __version__ as fastapi_version
|
| 38 |
+
app_logger.info(f"still alive... FastAPI version:{fastapi_version}, nltk version:{nltk_version}!")
|
| 39 |
return "Still alive..."
|
| 40 |
|
| 41 |
|
|
|
|
| 47 |
from my_ghost_writer.text_parsers import get_words_tokens_and_indexes
|
| 48 |
|
| 49 |
t0 = datetime.now()
|
| 50 |
+
app_logger.info(f"body type: {type(body)}.")
|
| 51 |
+
app_logger.debug(f"body: {body}.")
|
| 52 |
body = json.loads(body)
|
| 53 |
text = body["text"]
|
| 54 |
+
app_logger.info(f"LOG_LEVEL: '{LOG_LEVEL}', length of text: {len(text)}.")
|
| 55 |
+
app_logger.debug(f"text from request: {text} ...")
|
|
|
|
| 56 |
ps = PorterStemmer()
|
| 57 |
text_split_newline = text.split("\n")
|
| 58 |
row_words_tokens = []
|
|
|
|
| 62 |
row_offsets_tokens.append(WordPunctTokenizer().span_tokenize(row))
|
| 63 |
words_stems_dict = get_words_tokens_and_indexes(row_words_tokens, row_offsets_tokens, ps)
|
| 64 |
dumped = json.dumps(words_stems_dict)
|
| 65 |
+
app_logger.debug(f"dumped: {dumped} ...")
|
|
|
|
| 66 |
t1 = datetime.now()
|
| 67 |
duration = (t1 - t0).total_seconds()
|
| 68 |
n_total_rows = len(text_split_newline)
|
| 69 |
+
content_response = {'words_frequency': dumped, "duration": f"{duration:.3f}", "n_total_rows": n_total_rows}
|
| 70 |
+
app_logger.info(f"content_response: {content_response["duration"]}, {content_response["n_total_rows"]} ...")
|
| 71 |
+
app_logger.debug(f"content_response: {content_response} ...")
|
| 72 |
+
return JSONResponse(status_code=200, content=content_response)
|
| 73 |
+
|
| 74 |
|
| 75 |
+
app.mount("/static", StaticFiles(directory=STATIC_FOLDER, html=True), name="static")
|
| 76 |
|
| 77 |
+
# add the CorrelationIdMiddleware AFTER the @app.middleware("http") decorated function to avoid missing request id
|
| 78 |
+
app.add_middleware(CorrelationIdMiddleware)
|
| 79 |
|
| 80 |
|
| 81 |
@app.get("/")
|
| 82 |
@app.get("/static/")
|
| 83 |
def index() -> FileResponse:
|
| 84 |
+
return FileResponse(path=STATIC_FOLDER / "index.html", media_type="text/html")
|
| 85 |
|
| 86 |
|
| 87 |
if __name__ == "__main__":
|
| 88 |
try:
|
| 89 |
+
uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=bool(IS_TESTING))
|
| 90 |
except Exception as ex:
|
| 91 |
print(f"fastapi/gradio application {fastapi_title}, exception:{ex}!")
|
| 92 |
+
app_logger.exception(f"fastapi/gradio application {fastapi_title}, exception:{ex}!")
|
| 93 |
raise ex
|
my_ghost_writer/constants.py
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
|
|
| 1 |
from pathlib import Path
|
|
|
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
from pathlib import Path
|
| 3 |
+
import structlog
|
| 4 |
|
| 5 |
+
from my_ghost_writer import session_logger
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
PROJECT_ROOT_FOLDER = Path(__file__).parent.parent
|
| 9 |
+
STATIC_FOLDER = PROJECT_ROOT_FOLDER / "static"
|
| 10 |
+
ALLOWED_ORIGIN_LIST = os.getenv('ALLOWED_ORIGIN', 'http://localhost:7860').split(",")
|
| 11 |
+
LOG_JSON_FORMAT = bool(os.getenv("LOG_JSON_FORMAT"))
|
| 12 |
+
IS_TESTING = bool(os.getenv('IS_TESTING', ""))
|
| 13 |
+
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
| 14 |
+
session_logger.setup_logging(json_logs=LOG_JSON_FORMAT, log_level=LOG_LEVEL)
|
| 15 |
+
app_logger = structlog.stdlib.get_logger(__name__)
|
my_ghost_writer/middlewares.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from typing import Callable
|
| 3 |
+
|
| 4 |
+
import structlog
|
| 5 |
+
from asgi_correlation_id.context import correlation_id
|
| 6 |
+
from fastapi import Request, Response
|
| 7 |
+
from uvicorn.protocols.utils import get_path_with_query_string
|
| 8 |
+
|
| 9 |
+
from my_ghost_writer.constants import app_logger
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def logging_middleware(request: Request, call_next: Callable) -> Response:
|
| 13 |
+
"""
|
| 14 |
+
Logging middleware to inject a correlation id in a fastapi application. Requires:
|
| 15 |
+
- structlog.stdlib logger
|
| 16 |
+
- setup_logging (samgis_core.utilities.session_logger package)
|
| 17 |
+
- CorrelationIdMiddleware (asgi_correlation_id package)
|
| 18 |
+
See tests/web/test_middlewares.py for an example based on a real fastapi application.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
request: fastapi Request
|
| 22 |
+
call_next: next callable function
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
fastapi Response
|
| 26 |
+
|
| 27 |
+
"""
|
| 28 |
+
structlog.contextvars.clear_contextvars()
|
| 29 |
+
# These context vars will be added to all log entries emitted during the request
|
| 30 |
+
request_id = correlation_id.get()
|
| 31 |
+
app_logger.debug(f"request_id:{request_id}.")
|
| 32 |
+
structlog.contextvars.bind_contextvars(request_id=request_id)
|
| 33 |
+
|
| 34 |
+
start_time = time.perf_counter_ns()
|
| 35 |
+
# If the call_next raises an error, we still want to return our own 500 response,
|
| 36 |
+
# so we can add headers to it (process time, request ID...)
|
| 37 |
+
response = Response(status_code=500)
|
| 38 |
+
try:
|
| 39 |
+
response = await call_next(request)
|
| 40 |
+
except Exception:
|
| 41 |
+
# TODO: Validate that we don't swallow exceptions (unit test?)
|
| 42 |
+
structlog.stdlib.get_logger("api.error").exception("Uncaught exception")
|
| 43 |
+
raise
|
| 44 |
+
finally:
|
| 45 |
+
process_time = time.perf_counter_ns() - start_time
|
| 46 |
+
status_code = response.status_code
|
| 47 |
+
url = get_path_with_query_string(request.scope)
|
| 48 |
+
client_host = request.client.host
|
| 49 |
+
client_port = request.client.port
|
| 50 |
+
http_method = request.method
|
| 51 |
+
http_version = request.scope["http_version"]
|
| 52 |
+
# Recreate the Uvicorn access log format, but add all parameters as structured information
|
| 53 |
+
app_logger.info(
|
| 54 |
+
f"""{client_host}:{client_port} - "{http_method} {url} HTTP/{http_version}" {status_code}""",
|
| 55 |
+
http={
|
| 56 |
+
"url": str(request.url),
|
| 57 |
+
"status_code": status_code,
|
| 58 |
+
"method": http_method,
|
| 59 |
+
"request_id": request_id,
|
| 60 |
+
"version": http_version,
|
| 61 |
+
},
|
| 62 |
+
network={"client": {"ip": client_host, "port": client_port}},
|
| 63 |
+
duration=process_time,
|
| 64 |
+
)
|
| 65 |
+
response.headers["X-Process-Time"] = str(process_time / 10 ** 9)
|
| 66 |
+
return response
|
my_ghost_writer/session_logger.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
import structlog
|
| 5 |
+
from structlog.types import EventDict, Processor
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# https://github.com/hynek/structlog/issues/35#issuecomment-591321744
|
| 9 |
+
def rename_event_key(_, __, event_dict: EventDict) -> EventDict:
|
| 10 |
+
"""
|
| 11 |
+
Log entries keep the text message in the `event` field, but Datadog
|
| 12 |
+
uses the `message` field. This processor moves the value from one field to
|
| 13 |
+
the other.
|
| 14 |
+
See https://github.com/hynek/structlog/issues/35#issuecomment-591321744
|
| 15 |
+
"""
|
| 16 |
+
event_dict["message"] = event_dict.pop("event")
|
| 17 |
+
return event_dict
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def drop_color_message_key(_, __, event_dict: EventDict) -> EventDict:
|
| 21 |
+
"""
|
| 22 |
+
Uvicorn logs the message a second time in the extra `color_message`, but we don't
|
| 23 |
+
need it. This processor drops the key from the event dict if it exists.
|
| 24 |
+
"""
|
| 25 |
+
event_dict.pop("color_message", None)
|
| 26 |
+
return event_dict
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def setup_logging(json_logs: bool = False, log_level: str = "INFO"):
|
| 30 |
+
"""Enhance the configuration of structlog.
|
| 31 |
+
Needed for correlation id injection with fastapi middleware within the app.
|
| 32 |
+
After the use of logging_middleware() within the middlewares module (if present), add also the CorrelationIdMiddleware from
|
| 33 |
+
'asgi_correlation_id' package.
|
| 34 |
+
To change an input parameter like the log level, re-run the function changing the parameter
|
| 35 |
+
(no need to re-instantiate the logger instance: it's a hot change)
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
json_logs: set logs in json format
|
| 39 |
+
log_level: log level string
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
|
| 43 |
+
"""
|
| 44 |
+
timestamper = structlog.processors.TimeStamper(fmt="iso")
|
| 45 |
+
|
| 46 |
+
shared_processors: list[Processor] = [
|
| 47 |
+
structlog.contextvars.merge_contextvars,
|
| 48 |
+
structlog.stdlib.add_logger_name,
|
| 49 |
+
structlog.stdlib.add_log_level,
|
| 50 |
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
| 51 |
+
structlog.stdlib.ExtraAdder(),
|
| 52 |
+
drop_color_message_key,
|
| 53 |
+
timestamper,
|
| 54 |
+
structlog.processors.StackInfoRenderer(),
|
| 55 |
+
# adapted from https://www.structlog.org/en/stable/standard-library.html
|
| 56 |
+
# If the "exc_info" key in the event dict is either true or a
|
| 57 |
+
# sys.exc_info() tuple, remove "exc_info" and render the exception
|
| 58 |
+
# with traceback into the "exception" key.
|
| 59 |
+
structlog.processors.format_exc_info,
|
| 60 |
+
# If some value is in bytes, decode it to a Unicode str.
|
| 61 |
+
structlog.processors.UnicodeDecoder(),
|
| 62 |
+
# Add callsite parameters.
|
| 63 |
+
structlog.processors.CallsiteParameterAdder(
|
| 64 |
+
{
|
| 65 |
+
structlog.processors.CallsiteParameter.FUNC_NAME,
|
| 66 |
+
structlog.processors.CallsiteParameter.LINENO,
|
| 67 |
+
}
|
| 68 |
+
),
|
| 69 |
+
# Render the final event dict as JSON.
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
if json_logs:
|
| 73 |
+
# We rename the `event` key to `message` only in JSON logs, as Datadog looks for the
|
| 74 |
+
# `message` key but the pretty ConsoleRenderer looks for `event`
|
| 75 |
+
shared_processors.append(rename_event_key)
|
| 76 |
+
# Format the exception only for JSON logs, as we want to pretty-print them when
|
| 77 |
+
# using the ConsoleRenderer
|
| 78 |
+
shared_processors.append(structlog.processors.format_exc_info)
|
| 79 |
+
|
| 80 |
+
structlog.configure(
|
| 81 |
+
processors=shared_processors
|
| 82 |
+
+ [
|
| 83 |
+
# Prepare event dict for `ProcessorFormatter`.
|
| 84 |
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
| 85 |
+
],
|
| 86 |
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
| 87 |
+
cache_logger_on_first_use=True,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
log_renderer: structlog.types.Processor
|
| 91 |
+
if json_logs:
|
| 92 |
+
log_renderer = structlog.processors.JSONRenderer()
|
| 93 |
+
else:
|
| 94 |
+
log_renderer = structlog.dev.ConsoleRenderer()
|
| 95 |
+
|
| 96 |
+
formatter = structlog.stdlib.ProcessorFormatter(
|
| 97 |
+
# These run ONLY on `logging` entries that do NOT originate within
|
| 98 |
+
# structlog.
|
| 99 |
+
foreign_pre_chain=shared_processors,
|
| 100 |
+
# These run on ALL entries after the pre_chain is done.
|
| 101 |
+
processors=[
|
| 102 |
+
# Remove _record & _from_structlog.
|
| 103 |
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
| 104 |
+
log_renderer,
|
| 105 |
+
],
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
handler = logging.StreamHandler()
|
| 109 |
+
# Use OUR `ProcessorFormatter` to format all `logging` entries.
|
| 110 |
+
handler.setFormatter(formatter)
|
| 111 |
+
root_logger = logging.getLogger()
|
| 112 |
+
root_logger.addHandler(handler)
|
| 113 |
+
root_logger.setLevel(log_level.upper())
|
| 114 |
+
|
| 115 |
+
for _log in ["uvicorn", "uvicorn.error"]:
|
| 116 |
+
# Clear the log handlers for uvicorn loggers, and enable propagation
|
| 117 |
+
# so the messages are caught by our root logger and formatted correctly
|
| 118 |
+
# by structlog
|
| 119 |
+
logging.getLogger(_log).handlers.clear()
|
| 120 |
+
logging.getLogger(_log).propagate = True
|
| 121 |
+
|
| 122 |
+
# Since we re-create the access logs ourselves, to add all information
|
| 123 |
+
# in the structured log (see the `logging_middleware` in main.py), we clear
|
| 124 |
+
# the handlers and prevent the logs to propagate to a logger higher up in the
|
| 125 |
+
# hierarchy (effectively rendering them silent).
|
| 126 |
+
logging.getLogger("uvicorn.access").handlers.clear()
|
| 127 |
+
logging.getLogger("uvicorn.access").propagate = False
|
| 128 |
+
|
| 129 |
+
def handle_exception(exc_type, exc_value, exc_traceback):
|
| 130 |
+
"""
|
| 131 |
+
Log any uncaught exception instead of letting it be printed by Python
|
| 132 |
+
(but leave KeyboardInterrupt untouched to allow users to Ctrl+C to stop)
|
| 133 |
+
See https://stackoverflow.com/a/16993115/3641865
|
| 134 |
+
"""
|
| 135 |
+
if issubclass(exc_type, KeyboardInterrupt):
|
| 136 |
+
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
| 137 |
+
return
|
| 138 |
+
|
| 139 |
+
root_logger.error(
|
| 140 |
+
"Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
sys.excepthook = handle_exception
|
poetry.lock
CHANGED
|
@@ -35,6 +35,25 @@ doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)",
|
|
| 35 |
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
|
| 36 |
trio = ["trio (>=0.26.1)"]
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
[[package]]
|
| 39 |
name = "click"
|
| 40 |
version = "8.1.8"
|
|
@@ -262,7 +281,7 @@ version = "25.0"
|
|
| 262 |
description = "Core utilities for Python packages"
|
| 263 |
optional = false
|
| 264 |
python-versions = ">=3.8"
|
| 265 |
-
groups = ["test"]
|
| 266 |
files = [
|
| 267 |
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
|
| 268 |
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
|
@@ -745,5 +764,5 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)
|
|
| 745 |
|
| 746 |
[metadata]
|
| 747 |
lock-version = "2.1"
|
| 748 |
-
python-versions = ">=3.10"
|
| 749 |
-
content-hash = "
|
|
|
|
| 35 |
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
|
| 36 |
trio = ["trio (>=0.26.1)"]
|
| 37 |
|
| 38 |
+
[[package]]
|
| 39 |
+
name = "asgi-correlation-id"
|
| 40 |
+
version = "4.3.4"
|
| 41 |
+
description = "Middleware correlating project logs to individual requests"
|
| 42 |
+
optional = false
|
| 43 |
+
python-versions = "<4.0,>=3.8"
|
| 44 |
+
groups = ["webserver"]
|
| 45 |
+
files = [
|
| 46 |
+
{file = "asgi_correlation_id-4.3.4-py3-none-any.whl", hash = "sha256:36ce69b06c7d96b4acb89c7556a4c4f01a972463d3d49c675026cbbd08e9a0a2"},
|
| 47 |
+
{file = "asgi_correlation_id-4.3.4.tar.gz", hash = "sha256:ea6bc310380373cb9f731dc2e8b2b6fb978a76afe33f7a2384f697b8d6cd811d"},
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
[package.dependencies]
|
| 51 |
+
packaging = "*"
|
| 52 |
+
starlette = ">=0.18"
|
| 53 |
+
|
| 54 |
+
[package.extras]
|
| 55 |
+
celery = ["celery"]
|
| 56 |
+
|
| 57 |
[[package]]
|
| 58 |
name = "click"
|
| 59 |
version = "8.1.8"
|
|
|
|
| 281 |
description = "Core utilities for Python packages"
|
| 282 |
optional = false
|
| 283 |
python-versions = ">=3.8"
|
| 284 |
+
groups = ["test", "webserver"]
|
| 285 |
files = [
|
| 286 |
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
|
| 287 |
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
|
|
|
| 764 |
|
| 765 |
[metadata]
|
| 766 |
lock-version = "2.1"
|
| 767 |
+
python-versions = ">=3.10,<4.0.0"
|
| 768 |
+
content-hash = "af708e39d72b9dd8f996555d91962276cf82bfbdecb0aed7fbb64a615b59e1cf"
|
pyproject.toml
CHANGED
|
@@ -7,7 +7,7 @@ authors = [
|
|
| 7 |
]
|
| 8 |
license = {text = "AGPL-3.0"}
|
| 9 |
readme = "README.md"
|
| 10 |
-
requires-python = ">=3.10"
|
| 11 |
dependencies = [
|
| 12 |
"nltk (>=3.9.1,<4.0.0)",
|
| 13 |
"python-dotenv (>=1.1.0,<2.0.0)",
|
|
@@ -27,6 +27,7 @@ optional = true
|
|
| 27 |
[tool.poetry.group.webserver.dependencies]
|
| 28 |
fastapi = "^0.115.12"
|
| 29 |
uvicorn = "^0.34.2"
|
|
|
|
| 30 |
|
| 31 |
[tool.pytest.ini_options]
|
| 32 |
addopts = "--cov=my_ghost_writer --cov-report html"
|
|
|
|
| 7 |
]
|
| 8 |
license = {text = "AGPL-3.0"}
|
| 9 |
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.10,<4.0.0"
|
| 11 |
dependencies = [
|
| 12 |
"nltk (>=3.9.1,<4.0.0)",
|
| 13 |
"python-dotenv (>=1.1.0,<2.0.0)",
|
|
|
|
| 27 |
[tool.poetry.group.webserver.dependencies]
|
| 28 |
fastapi = "^0.115.12"
|
| 29 |
uvicorn = "^0.34.2"
|
| 30 |
+
asgi-correlation-id = "^4.3.4"
|
| 31 |
|
| 32 |
[tool.pytest.ini_options]
|
| 33 |
addopts = "--cov=my_ghost_writer --cov-report html"
|
tests/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from my_ghost_writer.constants import
|
| 2 |
|
| 3 |
|
| 4 |
-
EVENTS_FOLDER =
|
|
|
|
| 1 |
+
from my_ghost_writer.constants import PROJECT_ROOT_FOLDER
|
| 2 |
|
| 3 |
|
| 4 |
+
EVENTS_FOLDER = PROJECT_ROOT_FOLDER / "tests" / "events"
|