diff --git a/piggy/app.py b/piggy/app.py index 3e14038..df13193 100644 --- a/piggy/app.py +++ b/piggy/app.py @@ -7,10 +7,10 @@ from piggy import ASSIGNMENT_ROUTE, MEDIA_ROUTE, AssignmentTemplate from piggy.api import api_routes from piggy.api import generate_thumbnail -from piggy.caching import lru_cache_wrapper, _render_assignment, cache_directory, _render_assignment_wildcard +from piggy.caching import cache_directory, _render_assignment_wildcard from piggy.exceptions import PiggyHTTPException from piggy.piggybank import PIGGYMAP, get_piggymap_segment_from_path -from piggy.utils import normalize_path_to_str +from piggy.utils import normalize_path_to_str, lru_cache_wrapper # Ensure the working directory is the root of the project os.chdir(os.path.dirname(Path(__file__).parent.absolute())) @@ -19,9 +19,11 @@ # TODO: Logging -def create_app(): +def create_app(debug: bool = False) -> Flask: app = Flask(__name__, static_folder="static") + app.debug = debug + assignment_routes = Blueprint(ASSIGNMENT_ROUTE, __name__, url_prefix=f"/{ASSIGNMENT_ROUTE}") media_routes = Blueprint(MEDIA_ROUTE, __name__, url_prefix=f"/{MEDIA_ROUTE}") @@ -43,6 +45,7 @@ def context_processor(): } @app.template_global() + @lru_cache_wrapper def get_template_name_from_index(i: int): """Return the template path for the index.""" # Used to get the name of the template from the index via a path (breadcrumbs) @@ -55,6 +58,7 @@ def index(): @assignment_routes.route("/") @assignment_routes.route("/") + @lru_cache_wrapper def get_assignment_wildcard(path="", lang=""): path = path.strip("/") path = normalize_path_to_str(path, replace_spaces=True) @@ -75,6 +79,7 @@ def get_assignment_wildcard(path="", lang=""): @assignment_routes.route("//lang/") @assignment_routes.route("//lang/") + @lru_cache_wrapper def get_assignment_wildcard_lang(path, lang=""): """Only used when GitHub Pages is used to host the site.""" return get_assignment_wildcard(path, lang) @@ -86,6 +91,10 @@ def get_assignment_media_wildcard(wildcard, filename): Get a media file from either the media or attachments folder. (only in MEDIA_URL_PREFIX or ASSIGNMENT_URL_PREFIX) """ + + # TODO: This might be slower than necessary, but the demanding fns are cached in the LRU cache + # (This fn cannot be cached as it handles files) + if ["lang", "attachments"] == request.path.split("/")[-3:-1]: # If a language is specified, remove it from the wildcard (+ the assignment name) # This only happens when the language is specified in the URL and not via cookies @@ -111,8 +120,8 @@ def get_assignment_media_wildcard(wildcard, filename): app.register_blueprint(api_routes) # Cache all assignment related pages if not in debug mode - if not app.debug: + if os.environ.get("USE_CACHE", "1") == "1": with app.app_context(), app.test_request_context(): - cache_directory(PIGGYMAP, directory_fn=_render_assignment_wildcard, assignment_fn=_render_assignment) + cache_directory(PIGGYMAP, fn=get_assignment_wildcard) return app diff --git a/piggy/caching.py b/piggy/caching.py index 6dd8ca9..af25636 100644 --- a/piggy/caching.py +++ b/piggy/caching.py @@ -1,16 +1,16 @@ from pathlib import Path -from typing import Callable +from typing import Callable, Optional from flask import Response, render_template +from frozendict import deepfreeze from turtleconverter import mdfile_to_sections, ConversionError from piggy import ( ASSIGNMENT_ROUTE, MEDIA_ROUTE, - PIGGYBANK_FOLDER, AssignmentTemplate, ) -from piggy.exceptions import PiggyHTTPException +from piggy.exceptions import PiggyHTTPException, PiggyErrorException from piggy.models import LANGUAGES from piggy.piggybank import ( get_all_meta_from_path, @@ -22,40 +22,52 @@ from piggy.utils import ( get_supported_languages, generate_summary_from_mkdocs_html, - lru_cache_wrapper, normalize_path_to_str, + lru_cache_wrapper, ) def cache_directory( - segment: dict, directory_fn: Callable[[str], Response], assignment_fn: Callable[[Path], Response], _path: str = "" + segment: dict, + fn: Callable[[str, Optional[str]], Response], + _path: str = "", ): + """Cache the directory of assignments.""" for key, value in segment.items(): print(f"Caching: {_path}/{key}") - directory_fn(f"{_path}/{key}".strip("/")) + fn(f"{_path}/{key}".strip("/")) + # If we are just above the assignment level, its children will be the assignments if len(_path.split("/")) == AssignmentTemplate.ASSIGNMENT.index - 1: for assignment, assignment_data in value.get("data", {}).items(): - assignment_path = f"{_path}/{key}/{assignment}".strip("/") - assignment_path = Path(f"{PIGGYBANK_FOLDER}/{assignment_path}.md") - assignment_fn(assignment_path) + # Get the path of the assignment (Path object + assignment_path_obj = segment.get(key, {}).get("data", {}).get(assignment, {}).get("path", Path("")) + + # Set the assignment path to a string with the right url format + assignment_path = str(f"{_path}/{key}/{assignment}") + + if not assignment_path_obj.exists(): + raise PiggyErrorException(f"Assignment not found: {assignment_path}") + + fn(assignment_path, "") [ - assignment_fn(Path(f"{assignment_path.parent}/translations/{lang}/{assignment}.md")) + fn(f"{assignment_path}", lang) for lang in LANGUAGES.keys() - if Path(f"{assignment_path.parent}/translations/{lang}/{assignment}.md").exists() + if Path(f"{assignment_path_obj.parent}/translations/{lang}/{assignment}.md").exists() ] # If we are at the assignment level, we are done elif len(_path.split("/")) > AssignmentTemplate.ASSIGNMENT.index - 1: return else: - cache_directory( - value.get("data", {}), directory_fn=directory_fn, assignment_fn=assignment_fn, _path=f"{_path}/{key}" - ) + cache_directory(value.get("data", {}), fn=fn, _path=f"{_path}/{key}") @lru_cache_wrapper -def _render_assignment(p: Path, extra_metadata: dict = dict) -> Response: +def _render_assignment(p: Path, extra_metadata=None) -> Response: """Render an assignment from a Path object.""" + + extra_metadata = dict(extra_metadata) + if not p.exists(): raise PiggyHTTPException("Assignment not found", status_code=404) try: @@ -73,8 +85,8 @@ def _render_assignment(p: Path, extra_metadata: dict = dict) -> Response: current_language = LANGUAGES.get(lang, "")["name"] # Get the assignment data - assignment_data = get_assignment_data_from_path(assignment_path, PIGGYMAP).copy() - meta = assignment_data.get("meta", {}).copy() + assignment_data = dict(get_assignment_data_from_path(assignment_path, PIGGYMAP).copy()) + meta = dict(assignment_data.get("meta", {}).copy()) if "summary" not in meta: meta["summary"] = generate_summary_from_mkdocs_html(sections["body"]) assignment_data.pop("meta") @@ -102,6 +114,11 @@ def _render_assignment_wildcard(path="", lang="") -> Response: """ template_type = get_template_from_path(path) metadata, segment = get_piggymap_segment_from_path(path, PIGGYMAP) + + # If a piggymap segment is not found, raise a 404 + if not segment: + raise PiggyHTTPException("Page not found", status_code=404) + metadata = {**metadata, **get_all_meta_from_path(path, PIGGYMAP)} media_abspath = f"/{MEDIA_ROUTE}/{path}" if path else f"/{MEDIA_ROUTE}" @@ -120,7 +137,9 @@ def _render_assignment_wildcard(path="", lang="") -> Response: # If a language is specified, set the assignment path to the translation if lang: assignment = f"translations/{lang}/{assignment}" - return _render_assignment(Path(f"{path}/{assignment}"), extra_metadata=metadata) + + # Render the assignment with the metadata (must be deepfrozen to be hashable) + return _render_assignment(Path(f"{path}/{assignment}"), extra_metadata=deepfreeze(metadata)) # Render the appropriate template (if it is not the final level) return Response( diff --git a/piggy/exceptions.py b/piggy/exceptions.py index 8c35b5f..4ba444a 100644 --- a/piggy/exceptions.py +++ b/piggy/exceptions.py @@ -5,6 +5,10 @@ class PiggyException(Exception): pass +class PiggyErrorException(Exception): + pass + + class PiggyHTTPException(PiggyException): def __init__(self, message, status_code): self.message = message diff --git a/piggy/piggybank.py b/piggy/piggybank.py index 07ac340..a02cc01 100644 --- a/piggy/piggybank.py +++ b/piggy/piggybank.py @@ -2,6 +2,7 @@ import os from pathlib import Path +from frozendict.cool import deepfreeze from turtleconverter import mdfile_to_sections from piggy import AssignmentTemplate, PIGGYBANK_FOLDER, ASSIGNMENT_FILENAME_REGEX @@ -24,7 +25,7 @@ def load_meta_json(path: Path): def get_piggymap_segment_from_path(path: str or Path, piggymap: dict) -> tuple[dict, dict]: """Get the metadata and segment from a path.""" path = normalize_path_to_str(path, replace_spaces=True) - segment = dict(piggymap.copy()) + segment = piggymap.copy() meta = segment.get("meta", {}) for path in path.split("/"): if not path: @@ -39,7 +40,6 @@ def get_piggymap_segment_from_path(path: str or Path, piggymap: dict) -> tuple[d # TODO: these could probably be combined into one function -@lru_cache_wrapper def get_all_meta_from_path(path: str or Path, piggymap: dict) -> dict: """Get all metadata from a path.""" metadata = dict() @@ -67,7 +67,6 @@ def get_all_meta_from_path(path: str or Path, piggymap: dict) -> dict: # TODO: these could probably be combined into one function -@lru_cache_wrapper def get_assignment_data_from_path(path: str or Path, piggymap: dict) -> dict: """Get the assignment data from a path.""" path = normalize_path_to_str(path, replace_spaces=True, normalize_url=True, remove_ext=True) @@ -151,12 +150,13 @@ def recursive_sort(data): return recursive_sort(piggymap) -PIGGYMAP = generate_piggymap(PIGGYBANK_FOLDER) +# Piggymap must be hashable for sooper dooper speed +PIGGYMAP = deepfreeze(generate_piggymap(PIGGYBANK_FOLDER)) # DEVTOOL def __update_piggymap(): global PIGGYMAP print("Rebuilding piggymap") - PIGGYMAP = generate_piggymap(PIGGYBANK_FOLDER) + PIGGYMAP = deepfreeze(generate_piggymap(PIGGYBANK_FOLDER)) print("Piggymap rebuilt") diff --git a/piggy/thumbnails.py b/piggy/thumbnails.py index 4918aa4..4bf4a2a 100644 --- a/piggy/thumbnails.py +++ b/piggy/thumbnails.py @@ -1,9 +1,11 @@ +import textwrap from pathlib import Path -from piggy.caching import lru_cache_wrapper + import PIL.Image import PIL.ImageDraw import PIL.ImageFont -import textwrap + +from piggy.utils import lru_cache_wrapper # TODO: this is a mess. diff --git a/pyproject.toml b/pyproject.toml index ed838d2..70c3192 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "piggy" version = "0.1.0" description = "" -license = {file = "LICENSE.md"} +license = { file = "LICENSE.md" } readme = "README.md" dependencies = [ "flask~=3.0.3", @@ -10,6 +10,7 @@ dependencies = [ "gunicorn~=23.0.0", "pillow~=10.4.0", "beautifulsoup4~=4.12.3", + "frozendict==2.4.4", ] [build-system] diff --git a/run.py b/run.py index b973324..30b2945 100644 --- a/run.py +++ b/run.py @@ -1,8 +1,6 @@ import os import subprocess -from piggy.app import create_app - def run_tailwind(reload=False): cmd = ( @@ -21,26 +19,39 @@ def checkout_branch(branch): if __name__ == "__main__": - from piggy.devtools import inject_devtools - from piggy.piggybank import __update_piggymap + # Debug import logging + # Reduce the amount of logging from werkzeug log = logging.getLogger("werkzeug") log.setLevel(logging.ERROR) - os.environ["FLASK_DEBUG"] = "1" + + # Set the environment variables for testing os.environ["USE_CACHE"] = "0" - app = create_app() - inject_devtools(app) - __update_piggymap() + os.environ["FLASK_DEBUG"] = "1" + + # Run these once on the first run if os.environ.get("WERKZEUG_RUN_MAIN") != "true": # This code will run only once, not in the reloaded processes checkout_branch("test-output") - run_tailwind(reload=True) + run_tailwind(reload=True) # TODO: This does not keep watching for changes subprocess.Popen('npx livereload "piggy/, piggybank/"', shell=True) + # Import after setting the environment variables for testing + from piggy.app import create_app + from piggy.devtools import inject_devtools + from piggy.piggybank import __update_piggymap + + app = create_app(debug=os.environ.get("FLASK_DEBUG", False) == "1") + inject_devtools(app) # Inject devtools + __update_piggymap() # Run on every reload + app.run(port=5001) else: - # TODO: Re-enable + # Production + from piggy.app import create_app + + # TODO: Re-enable (requires branch to be published) (or a env to pass the branch with a PAT) # checkout_branch("output") run_tailwind() # Runs once to generate the CSS file - app = create_app() + app = create_app(debug=os.environ.get("FLASK_DEBUG", False) == "1")