├── .github ├── FUNDING.yml └── workflows │ ├── contributing.yml │ ├── latest-changes.yml │ └── test.yml ├── .gitignore ├── .python-version ├── Dockerfile ├── LICENSE.md ├── Makefile ├── README.md ├── changelog.md ├── pyproject.toml ├── src └── fastapi_blog │ ├── __init__.py │ ├── helpers.py │ ├── main.py │ ├── router.py │ └── templates │ ├── 404.html │ ├── index.html │ ├── layout │ └── base.html │ ├── page.html │ ├── partials │ └── _post_short.html │ ├── post.html │ ├── posts.html │ ├── tag.html │ └── tags.html └── tests ├── __init__.py ├── examples ├── defaults.py ├── favorite_post_ids.py ├── modified_all.py ├── pages │ └── about.md ├── posts │ ├── 2023-01-configuring-sphinx-auto-doc-with-django.md │ ├── 2023-01-converting-markdown-headers-to-checklist.md │ ├── 2023-01-practice-python-web-projects.md │ ├── 2023-01-resolutions.md │ ├── 2023-02-programming-languages-ive-learned.md │ ├── 2023-04-aws-requests-auth.md │ ├── 2023-04-cookiecutter-options-pattern.md │ ├── 2023-04-my-fitness-journey.md │ ├── 2023-06-bjj-training-tips.md │ ├── 2023-06-converting-from-bleach-to-nh3.md │ ├── 2023-07-visit-to-paultons-park.md │ ├── 2023-08-pypi-project-urls-cheatsheet.md │ ├── 2023-10-we-moved-to-london.md │ ├── 2023-11-minimal-css-libraries.md │ ├── 2023-11-splitting-git-commit.md │ ├── 2023-11-three-years-at-kraken-tech.md │ ├── code-code-code.md │ ├── no-tags.md │ └── thirty-minute-rule.md ├── prefix_change.py ├── prefix_none.py ├── static │ ├── custom.css │ ├── normalize.css │ ├── pygments.css │ └── sakura.css └── templates │ └── layout │ └── base.html ├── test_helpers.py └── test_router.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: pydanny 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | polar: # Replace with a single Polar username 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /.github/workflows/contributing.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Contributing 3 | on: 4 | push: 5 | branches: [main] 6 | jobs: 7 | contrib-readme-job: 8 | runs-on: ubuntu-latest 9 | name: A job to automate contrib in readme 10 | steps: 11 | - name: Contribute List 12 | uses: akhilmhdh/contributors-readme-action@v2.3.6 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/latest-changes.yml: -------------------------------------------------------------------------------- 1 | name: Latest Changes 2 | 3 | on: 4 | pull_request_target: 5 | branches: 6 | - main 7 | # Or use the branch "master" if that's your main branch: 8 | # - master 9 | types: 10 | - closed 11 | # For manually triggering it 12 | workflow_dispatch: 13 | inputs: 14 | number: 15 | description: PR number 16 | required: true 17 | 18 | jobs: 19 | latest-changes: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | token: ${{ secrets.REPO_TOKEN }} 25 | - uses: tiangolo/latest-changes@0.2.1 26 | with: 27 | token: ${{ secrets.REPO_TOKEN }} 28 | latest_changes_file: changelog.md 29 | latest_changes_header: '# Changelog' -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | on: [push] 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | python-version: ['3.10', '3.11', '3.12'] 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Set up Python ${{ matrix.python-version }} 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: ${{ matrix.python-version }} 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install '.[dev]' 20 | - name: Lint with ruff 21 | run: | 22 | # stop the build if there are linting errors 23 | make lint 24 | - name: Test with pytest 25 | run: |- 26 | pytest . 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | .hypothesis/ 163 | .vscode/ 164 | .ruff_cache/ 165 | changelog.json 166 | .idea 167 | .DS_Store 168 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile for building a FastAPI Blog image 2 | # This is a multi-stage build 3 | # The first stage is to build the application 4 | # The second stage is to run the application 5 | 6 | # Use the official image as a parent image 7 | FROM python:3.12-alpine AS builder 8 | 9 | # Set the working directory in the container 10 | WORKDIR /app 11 | 12 | # Copy the current directory contents into the container at /app 13 | COPY . /app 14 | 15 | # Install make 16 | RUN apk add --no-cache make 17 | 18 | # Install fastapi-blog for local development 19 | # This installs any needed packages specified in pyproject.toml 20 | # as local development dependencies 21 | RUN make install 22 | 23 | # Run tests 24 | RUN make test 25 | 26 | # Configure the container to run the application 27 | ENV PORT=8000 28 | 29 | # Expose the port the app runs on 30 | EXPOSE 8000 31 | 32 | # Run the application 33 | # RUN make run 34 | # CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT} 35 | CMD uvicorn tests.example.main:app --host 0.0.0.0 --port ${PORT} 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Functional Source License, Version 1.1, Apache 2.0 Future License 2 | 3 | ## Abbreviation 4 | 5 | FSL-1.1-Apache-2.0 6 | 7 | ## Notice 8 | 9 | Copyright 2008-2024 Daniel Roy Greenfeld 10 | 11 | ## Terms and Conditions 12 | 13 | ### Licensor ("We") 14 | 15 | The party offering the Software under these Terms and Conditions. 16 | 17 | ### The Software 18 | 19 | The "Software" is each version of the software that we make available under 20 | these Terms and Conditions, as indicated by our inclusion of these Terms and 21 | Conditions with the Software. 22 | 23 | ### License Grant 24 | 25 | Subject to your compliance with this License Grant and the Patents, 26 | Redistribution and Trademark clauses below, we hereby grant you the right to 27 | use, copy, modify, create derivative works, publicly perform, publicly display 28 | and redistribute the Software for any Permitted Purpose identified below. 29 | 30 | ### Permitted Purpose 31 | 32 | A Permitted Purpose is any purpose other than a Competing Use. A Competing Use 33 | means making the Software available to others in a commercial product or 34 | service that: 35 | 36 | 1. substitutes for the Software; 37 | 38 | 2. substitutes for any other product or service we offer using the Software 39 | that exists as of the date we make the Software available; or 40 | 41 | 3. offers the same or substantially similar functionality as the Software. 42 | 43 | Permitted Purposes specifically include using the Software: 44 | 45 | 1. for your internal use and access; 46 | 47 | 2. for non-commercial education; 48 | 49 | 3. for non-commercial research; and 50 | 51 | 4. in connection with professional services that you provide to a licensee 52 | using the Software in accordance with these Terms and Conditions. 53 | 54 | ### Patents 55 | 56 | To the extent your use for a Permitted Purpose would necessarily infringe our 57 | patents, the license grant above includes a license under our patents. If you 58 | make a claim against any party that the Software infringes or contributes to 59 | the infringement of any patent, then your patent license to the Software ends 60 | immediately. 61 | 62 | ### Redistribution 63 | 64 | The Terms and Conditions apply to all copies, modifications and derivatives of 65 | the Software. 66 | 67 | If you redistribute any copies, modifications or derivatives of the Software, 68 | you must include a copy of or a link to these Terms and Conditions and not 69 | remove any copyright notices provided in or with the Software. 70 | 71 | ### Disclaimer 72 | 73 | THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR 74 | IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR 75 | PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. 76 | 77 | IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE 78 | SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, 79 | EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. 80 | 81 | ### Trademarks 82 | 83 | Except for displaying the License Details and identifying us as the origin of 84 | the Software, you have no right under these Terms and Conditions to use our 85 | trademarks, trade names, service marks or product names. 86 | 87 | ## Grant of Future License 88 | 89 | We hereby irrevocably grant you an additional license to use the Software under 90 | the Apache License, Version 2.0 that is effective on the second anniversary of 91 | the date we make the Software available. On or after that date, you may use the 92 | Software under the Apache License, Version 2.0, in which case the following 93 | will apply: 94 | 95 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use 96 | this file except in compliance with the License. 97 | 98 | You may obtain a copy of the License at 99 | 100 | http://www.apache.org/licenses/LICENSE-2.0 101 | 102 | Unless required by applicable law or agreed to in writing, software distributed 103 | under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 104 | CONDITIONS OF ANY KIND, either express or implied. See the License for the 105 | specific language governing permissions and limitations under the License. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | 3 | VERSION=v$(shell grep -m 1 version pyproject.toml | tr -s ' ' | tr -d '"' | tr -d "'" | cut -d' ' -f3) 4 | 5 | tag: 6 | echo "Tagging version $(VERSION)" 7 | git tag -a $(VERSION) -m "Creating version $(VERSION)" 8 | git push origin $(VERSION) 9 | 10 | install: 11 | pip install uv 12 | uv pip install -e '.[dev]' 13 | 14 | all: 15 | make lint 16 | make mypy 17 | make test 18 | 19 | lint: 20 | ruff check . 21 | ruff format . --check 22 | 23 | format: 24 | ruff check . --fix 25 | ruff format . 26 | 27 | mypy: 28 | mypy . 29 | 30 | test: 31 | coverage run -m pytest . 32 | coverage report -m 33 | coverage html 34 | 35 | test-pdb: 36 | pytest --pdb . 37 | 38 | run: 39 | make run_defaults 40 | 41 | run_defaults: 42 | cd tests/examples && uvicorn defaults:app --reload 43 | 44 | run_modified_all: 45 | cd tests/examples && uvicorn modified_all:app --reload 46 | 47 | run_prefix_change: 48 | cd tests/examples && uvicorn prefix_change:app --reload 49 | 50 | run_prefix_none: 51 | cd tests/examples && uvicorn prefix_none:app --reload 52 | 53 | run_favorite_post_ids: 54 | cd tests/examples && uvicorn favorite_post_ids:app --reload -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Blog 2 | 3 | A simple, easy-to-use blog application built with FastAPI. 4 | 5 | ## Features 6 | 7 | - Write blog posts in Markdown 8 | - Syntax highlighting for code blocks 9 | - Responsive design 10 | - Dark mode 11 | - Overloadable templates 12 | - [Live, working configuration examples](https://github.com/pydanny/fastapi-blog/tree/main/tests/examples) 13 | - SEO-friendly 14 | - Sitemap 15 | - Docker support 16 | 17 | ## Basic Usage 18 | 19 | 1. Import the `add_blog_to_fastapi` function 20 | 2. Run the instantiated FastAPI app through the `add_blog_to_fastapi` function 21 | 22 | This all you need to do: 23 | 24 | ```python 25 | from fastapi_blog import add_blog_to_fastapi 26 | from fastapi import FastAPI 27 | 28 | 29 | app = FastAPI() 30 | app = add_blog_to_fastapi(app) 31 | 32 | 33 | @app.get("/") 34 | async def index() -> dict: 35 | return { 36 | "message": "Check out the blog at the URL", 37 | "url": "http://localhost:8000/blog", 38 | } 39 | ``` 40 | 41 | 3. Add the first blog entry 42 | 43 | Assuming your FastAPI app is defined in a `main.py` module at the root of your project, create a file at `posts/first-blog-post.md`: 44 | 45 | ```markdown 46 | --- 47 | date: "2024-03-21T22:20:50.52Z" 48 | published: true 49 | tags: 50 | - fastapi 51 | - fastapi-blog 52 | title: First blog post 53 | description: This is the first blog post entry. 54 | --- 55 | 56 | Exciting times in the world of fastapi-blog are ahead! 57 | 58 | ## This is a markdown header 59 | 60 | And this is a markdown paragraph with a [link](https://github.com/pydanny/fastapi-blog). 61 | ``` 62 | 63 | 4. Add the first page 64 | 65 | Assuming your FastAPI app is defined in a `main.py` module at the root of your project, create a file at `pages/about.md`: 66 | 67 | ```markdown 68 | --- 69 | title: "About Me" 70 | description: "A little bit of background about me" 71 | author: "Daniel Roy Greenfeld" 72 | --- 73 | 74 | ## Intro about me 75 | 76 | I'm probably best known as "[pydanny](https://www.google.com/search?q=pydanny)", one of the authors of Two Scoops of Django. 77 | 78 | I love to hang out with my [wife](https://audrey.feldroy.com/), play with my [daughter](/tags/uma), do [Brazilian Jiu-Jitsu](https://academyofbrazilianjiujitsu.com/), write [books](/books), and read books. 79 | 80 | - [Mastodon](https://fosstodon.org/@danielfeldroy) 81 | - [LinkedIn](https://www.linkedin.com/in/danielfeldroy/) 82 | - [Twitter](https://twitter.com/pydanny) 83 | 84 | ## About this site 85 | 86 | This site is written in: 87 | 88 | - Python 89 | - FastAPI 90 | - fastapi-blog 91 | - Sakura minimal CSS framework 92 | - Markdown 93 | - Vanilla HTML 94 | ``` 95 | 96 | 97 | ## Advanced Usage 98 | 99 | fastapi_blog is configurable through the `add_blog_to_fastapi` function. 100 | 101 | ### Adding app-controlled static media 102 | 103 | Change the main app to mount StaticFiles: 104 | 105 | ```python 106 | from fastapi_blog import add_blog_to_fastapi 107 | from fastapi import FastAPI 108 | from fastapi.staticfiles import StaticFiles 109 | 110 | 111 | app = FastAPI() 112 | app = add_blog_to_fastapi(app) 113 | app.mount("/static", StaticFiles(directory="static"), name="static") 114 | 115 | @app.get("/") 116 | async def index() -> dict: 117 | return { 118 | "message": "Check out the blog at the URL", 119 | "url": "http://localhost:8000/blog", 120 | } 121 | ``` 122 | 123 | ### Replacing the default templates 124 | 125 | This example is Django-like in that your local templates will overload the default ones. 126 | 127 | ```python 128 | import fastapi_blog 129 | import jinja2 130 | from fastapi import FastAPI 131 | from fastapi.staticfiles import StaticFiles 132 | 133 | 134 | django_style_jinja2_loader = jinja2.ChoiceLoader( 135 | [ 136 | jinja2.FileSystemLoader("templates"), 137 | jinja2.PackageLoader("fastapi_blog", "templates"), 138 | ] 139 | ) 140 | 141 | app = FastAPI() 142 | app = fastapi_blog.add_blog_to_fastapi( 143 | app, prefix=prefix, jinja2_loader=django_style_jinja2_loader 144 | ) 145 | app.mount("/static", StaticFiles(directory="static"), name="static") 146 | 147 | 148 | @app.get("/") 149 | async def index() -> dict: 150 | return { 151 | "message": "Check out the blog at the URL", 152 | "url": f"http://localhost:8000/blog", 153 | } 154 | ``` 155 | 156 | 157 | ### Changing the location of the blog url 158 | 159 | Perhaps you want to have the blog at the root? 160 | 161 | ```python 162 | import fastapi_blog 163 | from fastapi import FastAPI 164 | 165 | 166 | app = FastAPI() 167 | app = fastapi_blog.add_blog_to_fastapi( 168 | app, prefix="change" 169 | ) 170 | 171 | 172 | @app.get("/api") 173 | async def index() -> dict: 174 | return { 175 | "message": "Check out the blog at the URL", 176 | "url": "http://localhost:8000/change", 177 | } 178 | ``` 179 | 180 | ## Blog at root URL 181 | 182 | This is for when your blog/CMS needs to be at the root of the project 183 | 184 | ```python 185 | import fastapi_blog 186 | from fastapi import FastAPI 187 | 188 | 189 | app = FastAPI() 190 | 191 | 192 | @app.get("/api") 193 | async def index() -> dict: 194 | return { 195 | "message": "Check out the blog at the URL", 196 | "url": "http://localhost:8000", 197 | } 198 | 199 | # Because the prefix is None, the call to add_blog_to_fastapi 200 | # needs to happen after the other view functions are defined. 201 | app = fastapi_blog.add_blog_to_fastapi(app, prefix=None) 202 | ``` 203 | 204 | 205 | ## Add favorite articles to the homepage 206 | 207 | ```python 208 | import fastapi_blog 209 | from fastapi import FastAPI 210 | 211 | 212 | favorite_post_ids = { 213 | "code-code-code", 214 | "thirty-minute-rule", 215 | "2023-11-three-years-at-kraken-tech", 216 | } 217 | 218 | app = FastAPI() 219 | app = fastapi_blog.add_blog_to_fastapi(app, favorite_post_ids=favorite_post_ids) 220 | 221 | 222 | @app.get("/") 223 | async def index() -> dict: 224 | return { 225 | "message": "Check out the blog at the URL", 226 | "url": "http://localhost:8000/blog", 227 | } 228 | ``` 229 | 230 | ### Add page not in the blog list of posts 231 | 232 | In the `pages` directory of your blog, add markdown files with frontmatter. You can then find it by going to the URL with that name. For example, adding this `pages/about.md` to the default config would make this appear at http://localhost:8000/blog/about. 233 | 234 | ```markdown 235 | --- 236 | title: "About Daniel Roy Greenfeld" 237 | description: "A little bit of background about Daniel Roy Greenfeld" 238 | author: "Daniel Roy Greenfeld" 239 | --- 240 | 241 | I'm probably best known as "[pydanny](https://www.google.com/search?q=pydanny)", one of the authors of [Two Scoops of Django](/books/tech). 242 | ``` 243 | 244 | 245 | ## Installation and Running Example Sites 246 | 247 | ### Option 1: Local Virtualenv 248 | 249 | You can install this into a virtualenv using the pyproject.toml file: 250 | 251 | ```bash 252 | pip install fastapi-blog 253 | make run 254 | ``` 255 | 256 | ### Option 2: Docker (Local Dockerfile) 257 | 258 | Or into a Docker container using the local Dockerfile: 259 | 260 | ```bash 261 | docker build -t fastapi-blog . 262 | docker run -d -p 8000:8000 fastapi-blog 263 | ``` 264 | 265 | ### Option 3: Docker (Prebuilt) 266 | 267 | Or using a prebuilt Docker image from GitHub Container Registry: 268 | 269 | ```bash 270 | docker run -d -p 8000:8000 ghcr.io/aroygreenfeld/fastapi-blog:latest 271 | ``` 272 | 273 | This is if you just want to run the application without building it yourself. 274 | 275 | ## Releasing a new version 276 | 277 | 1. Update the version in `pyproject.toml` and `fastapi_blog/__init__.py` 278 | 279 | 2. Update changelog.md 280 | 281 | 3. Build the distribution locally: 282 | 283 | ```bash 284 | rm -rf dist 285 | pip install -U build 286 | python -m build 287 | ``` 288 | 289 | 4. Upload the distribution to PyPI: 290 | 291 | ```bash 292 | pip install -U twine 293 | python -m twine upload dist/* 294 | ``` 295 | 296 | 5. Create a new release on GitHub and tag the release: 297 | 298 | ```bash 299 | git commit -am "Release for vXYZ" 300 | make tag 301 | ``` 302 | 303 | ## Contributors 304 | 305 | 306 | 307 | 308 | 315 | 322 |
309 | 310 | pydanny 311 |
312 | Daniel Roy Greenfeld 313 |
314 |
316 | 317 | audreyfeldroy 318 |
319 | Audrey Roy Greenfeld 320 |
321 |
323 | 324 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | * Remove staticfiles and encourage self config. PR [#40](https://github.com/pydanny/fastapi-blog/pull/40) by [@pydanny](https://github.com/pydanny). 4 | * Posts with zero tags no longer generate errors. PR [#39](https://github.com/pydanny/fastapi-blog/pull/39) by [@pydanny](https://github.com/pydanny). 5 | 6 | ## 0.6.0 - 2023-03-24 7 | 8 | * Add tutorial for pages. PR [#36](https://github.com/pydanny/fastapi-blog/pull/36) by [@pydanny](https://github.com/pydanny). 9 | * Add tutorial for blog entries. PR [#35](https://github.com/pydanny/fastapi-blog/pull/35) by [@pydanny](https://github.com/pydanny). 10 | * Allow for control over if statics are mounted. PR [#31](https://github.com/pydanny/fastapi-blog/pull/31) by [@pydanny](https://github.com/pydanny). 11 | * Fix markdown issue with pygments (#22). Thanks to @pydanny 12 | * Add header permalinks to rendered markdown (#22). Thanks to @pydanny 13 | 14 | ## 0.5.0 - 2023-03-08 15 | 16 | - Added continuous integration (#19). Thanks to @pydanny 17 | - Remove RSS feed as it needs a complete rebuild. Thanks to @pydanny 18 | - Use uv for local installation. Thanks to @pydanny 19 | - Inform PyPI the changelog is at changelog.md, not CHANGELOG. Thanks to @pydanny 20 | 21 | ## 0.4.0 - 2024-03-01 22 | 23 | - Document how to use pages (#3) and added sample `about.md` page. Thanks to @pydanny 24 | - Standardize path arguments with `_id` suffix (#7) Thanks to @pydanny 25 | - Initial tests for helpers.py, for #10. Thanks to @pydanny! 26 | - Remove hardcoded favorites list, issue #13. Thanks to @pydanny! 27 | 28 | ## 0.3.0 - 2024-02-29 29 | 30 | - Docker thanks to @audreyfeldroy! 31 | - Installation and usage instructions for localdev and docker. Thanks to @audreyfeldroy! 32 | - Made templates overloadable (issue #2) thanks to @pydanny! 33 | - Added more example apps thanks to @pydanny! 34 | 35 | ## 0.2.0 - 2024-02-25 36 | 37 | - Cleanup 38 | 39 | ## 0.1.0 - 2024-02-25 40 | 41 | - Inception 42 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "fastapi-blog" 7 | version = "0.6.0" 8 | description = "Blogging for FastAPI" 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | authors = [ 12 | {name = "Daniel Roy Greenfeld", email = "daniel@feldroy.com"}, 13 | ] 14 | maintainers = [ 15 | {name = "Daniel Roy Greenfeld", email = "daniel@feldroy.com"}, 16 | ] 17 | classifiers = [ 18 | "Development Status :: 2 - Pre-Alpha", 19 | "Environment :: Web Environment", 20 | "Framework :: FastAPI", 21 | "License :: Other/Proprietary License", 22 | "Programming Language :: Python :: 3.12", 23 | "Topic :: Text Processing :: Markup :: Markdown", 24 | ] 25 | license = {text = "FSL-1.0-Apache-2.0"} 26 | dependencies = [ 27 | "fastapi>=0.109.2", 28 | "jinja2>=3.1.3", 29 | "jinja2-time>=0.2.0", 30 | "markdown>=3.5.2", 31 | "pyyaml>=6.0.1", 32 | "pymdown-extensions>=10.7", 33 | "uvicorn>=0.27.1", 34 | ] 35 | 36 | [project.optional-dependencies] 37 | dev = [ 38 | "httpx", 39 | "ruff==0.2.2", 40 | "pytest==8.0.1", 41 | "coverage==7.4.1", 42 | "mypy==1.8.0", 43 | "types-Markdown==3.5.0.20240129", 44 | "types-PyYAML==6.0.12.12", 45 | ] 46 | 47 | [project.urls] 48 | 49 | bugs = "https://github.com/pydanny/fastapi-blog/issues" 50 | changelog = "https://github.com/pydanny/fastapi-blog/blob/master/changelog.md" 51 | homepage = "https://github.com/pydanny/fastapi-blog" 52 | 53 | [tool.setuptools] 54 | package-dir = {"" = "src"} 55 | 56 | [tool.setuptools.package-data] 57 | "*" = ["*.*"] 58 | 59 | 60 | # Mypy 61 | # ---- 62 | 63 | [tool.mypy] 64 | files = "." 65 | exclude = [ 66 | "tests/*" 67 | ] 68 | 69 | # Use strict defaults 70 | # strict = true 71 | # warn_unreachable = true 72 | # warn_no_return = true 73 | 74 | 75 | 76 | # Ruff 77 | # ---- 78 | 79 | [tool.ruff] 80 | lint.select = [ 81 | # Pyflakes 82 | "F", 83 | # Pycodestyle 84 | "E", 85 | "W", 86 | # isort 87 | "I001" 88 | ] 89 | lint.ignore = [ 90 | "E501", # line too long - black takes care of this for us 91 | ] 92 | 93 | [tool.ruff.lint.per-file-ignores] 94 | # Allow unused imports in __init__ files as these are convenience imports 95 | "**/__init__.py" = [ "F401" ] 96 | 97 | [tool.ruff.lint.isort] 98 | lines-after-imports = 2 99 | section-order = [ 100 | "future", 101 | "standard-library", 102 | "third-party", 103 | "first-party", 104 | "project", 105 | "local-folder", 106 | ] 107 | 108 | [tool.ruff.lint.isort.sections] 109 | "project" = [ 110 | "src", 111 | "tests", 112 | ] 113 | 114 | [tool.ruff.format] 115 | docstring-code-format = true 116 | docstring-code-line-length = 20 117 | 118 | 119 | [tool.coverage.run] 120 | source = ["src"] 121 | -------------------------------------------------------------------------------- /src/fastapi_blog/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import jinja2 4 | from fastapi import FastAPI 5 | from fastapi.templating import Jinja2Templates 6 | 7 | from .main import add_blog_to_fastapi 8 | from .router import get_blog_router 9 | 10 | 11 | __version__ = "0.6.0" 12 | -------------------------------------------------------------------------------- /src/fastapi_blog/helpers.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import pathlib 3 | 4 | import markdown as md #  type: ignore[import-untyped] 5 | import yaml 6 | from pymdownx import emoji # type: ignore 7 | 8 | 9 | @functools.lru_cache 10 | def list_posts(published: bool = True, posts_dirname="posts") -> list[dict]: 11 | posts: list[dict] = [] 12 | for post in pathlib.Path(".").glob(f"{posts_dirname}/*.md"): 13 | raw: str = post.read_text().split("---")[1] 14 | data: dict = yaml.safe_load(raw) 15 | data["slug"] = post.stem 16 | posts.append(data) 17 | 18 | posts = [x for x in filter(lambda x: x["published"] is True, posts)] 19 | 20 | posts.sort(key=lambda x: x["date"], reverse=True) 21 | return [x for x in filter(lambda x: x["published"] is published, posts)] 22 | 23 | 24 | def load_content_from_markdown_file(path: pathlib.Path) -> dict[str, str | dict]: 25 | raw: str = path.read_text() 26 | # Metadata is the first part of the file 27 | page = {} 28 | page["metadata"] = yaml.safe_load(raw.split("---")[1]) 29 | 30 | # Content is the second part of the file 31 | content_list: list = raw.split("---")[2:] 32 | page["markdown"] = "\n---\n".join(content_list) 33 | page["html"] = markdown(page["markdown"]) 34 | 35 | return page 36 | 37 | 38 | extensions = [ 39 | "markdown.extensions.tables", 40 | "toc", # "markdown.extensions.toc 41 | # "markdown.extensions.toc", 42 | "pymdownx.magiclink", 43 | "pymdownx.betterem", 44 | "pymdownx.tilde", 45 | "pymdownx.emoji", 46 | "pymdownx.tasklist", 47 | "pymdownx.superfences", 48 | "pymdownx.saneheaders", 49 | ] 50 | 51 | extension_configs = { 52 | "markdown.extensions.toc": { 53 | "permalink": True, 54 | "permalink_leading": True, 55 | "title": "Tabula Rasa", 56 | }, 57 | "pymdownx.magiclink": { 58 | "repo_url_shortener": True, 59 | "repo_url_shorthand": True, 60 | "provider": "github", 61 | "user": "facelessuser", 62 | "repo": "pymdown-extensions", 63 | }, 64 | "pymdownx.tilde": {"subscript": False}, 65 | "pymdownx.emoji": { 66 | "emoji_index": emoji.gemoji, 67 | "emoji_generator": emoji.to_png, 68 | "alt": "short", 69 | "options": { 70 | "attributes": {"align": "absmiddle", "height": "20px", "width": "20px"}, 71 | "image_path": "https://github.githubassets.com/images/icons/emoji/unicode/", 72 | "non_standard_image_path": "https://github.githubassets.com/images/icons/emoji/", 73 | }, 74 | }, 75 | "toc": { 76 | "title": "Table of Contents!", # Title for the table of contents 77 | "anchorlink": True, # Add anchor links to the headers 78 | "permalink": "# ", # Add permanent links to the headers 79 | "permalink_leading": True, # Add permanent links to the headers 80 | }, 81 | } 82 | 83 | markdown = functools.partial( 84 | md.markdown, extensions=extensions, extension_configs=extension_configs 85 | ) 86 | -------------------------------------------------------------------------------- /src/fastapi_blog/main.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import jinja2 4 | from fastapi import FastAPI 5 | from fastapi.templating import Jinja2Templates 6 | 7 | from .router import get_blog_router 8 | 9 | 10 | def add_blog_to_fastapi( 11 | app: FastAPI, 12 | prefix: str | None = "blog", 13 | jinja2_loader: jinja2.BaseLoader = jinja2.PackageLoader( 14 | "fastapi_blog", "templates" 15 | ), 16 | jinja2_extensions: set[str] = { 17 | "jinja2_time.TimeExtension", 18 | "jinja2.ext.debug", 19 | }, 20 | favorite_post_ids: set[str] = set(), 21 | mount_statics: bool = True, 22 | ) -> FastAPI: 23 | # Prep the templates 24 | env = jinja2.Environment( 25 | loader=jinja2_loader, 26 | extensions=list(jinja2_extensions), 27 | ) 28 | templates = Jinja2Templates(env=env) 29 | 30 | # Router controls 31 | router = get_blog_router(templates=templates, favorite_post_ids=favorite_post_ids) 32 | router_kwargs: dict[str, Any] = {"router": router, "tags": ["blog"]} 33 | if prefix is not None: 34 | router_kwargs["prefix"] = f"/{prefix}" 35 | app.include_router(**router_kwargs) 36 | 37 | return app 38 | -------------------------------------------------------------------------------- /src/fastapi_blog/router.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import pathlib 3 | from typing import Any 4 | 5 | from fastapi import APIRouter, Request 6 | from fastapi.responses import HTMLResponse 7 | from fastapi.templating import Jinja2Templates 8 | 9 | from . import helpers 10 | 11 | 12 | def get_blog_router( 13 | templates: Jinja2Templates, favorite_post_ids: set[str] = set() 14 | ) -> APIRouter: 15 | router = APIRouter() 16 | 17 | @router.get("/") 18 | async def blog_index(request: Request, response_class=HTMLResponse): 19 | posts = helpers.list_posts() 20 | recent_3 = posts[:3] 21 | 22 | favorite_posts: list[dict[Any, Any]] = list( 23 | filter(lambda x: x["slug"] in favorite_post_ids, posts) 24 | ) 25 | 26 | return templates.TemplateResponse( 27 | request=request, 28 | name="index.html", 29 | context={"recent_3": recent_3, "favorite_posts": favorite_posts}, 30 | ) 31 | 32 | @router.get("/posts/{post_id}") 33 | async def blog_post(post_id: str, request: Request, response_class=HTMLResponse): 34 | post = [ 35 | x for x in filter(lambda x: x["slug"] == post_id, helpers.list_posts()) 36 | ][0] 37 | content = pathlib.Path(f"posts/{post_id}.md").read_text().split("---")[2] 38 | post["content"] = helpers.markdown(content) 39 | 40 | return templates.TemplateResponse( 41 | request=request, name="post.html", context={"post": post} 42 | ) 43 | 44 | @router.get("/posts") 45 | async def blog_posts(request: Request, response_class=HTMLResponse): 46 | posts: list[dict] = helpers.list_posts() 47 | 48 | posts.sort(key=lambda x: x["date"], reverse=True) 49 | 50 | return templates.TemplateResponse( 51 | request=request, name="posts.html", context={"posts": posts} 52 | ) 53 | 54 | @router.get("/tags") 55 | async def blog_tags(request: Request, response_class=HTMLResponse): 56 | posts: list[dict] = helpers.list_posts() 57 | 58 | unsorted_tags: dict = {} 59 | for post in posts: 60 | page_tags = post.get("tags", []) or [] 61 | for tag in page_tags: 62 | if tag in unsorted_tags: 63 | unsorted_tags[tag] += 1 64 | else: 65 | unsorted_tags[tag] = 1 66 | 67 | # Sort by value (number of articles per tag) 68 | tags: dict = collections.OrderedDict( 69 | sorted(unsorted_tags.items(), key=lambda x: x[1], reverse=True) 70 | ) 71 | 72 | return templates.TemplateResponse( 73 | request=request, name="tags.html", context={"tags": tags} 74 | ) 75 | 76 | @router.get("/tags/{tag_id}") 77 | async def blog_tag(tag_id: str, request: Request, response_class=HTMLResponse): 78 | posts: list[dict] = helpers.list_posts() 79 | posts = [x for x in filter(lambda x: tag_id in x.get("tags", []), posts)] 80 | 81 | return templates.TemplateResponse( 82 | request=request, name="tag.html", context={"tag_id": tag_id, "posts": posts} 83 | ) 84 | 85 | @router.get("/{page_id}") 86 | async def blog_page(page_id: str, request: Request, response_class=HTMLResponse): 87 | path = pathlib.Path(f"pages/{page_id}.md") 88 | try: 89 | page: dict[str, str | dict] = helpers.load_content_from_markdown_file(path) 90 | except FileNotFoundError: 91 | return templates.TemplateResponse( 92 | request=request, name="404.html", status_code=404 93 | ) 94 | page["slug"] = page_id 95 | 96 | return templates.TemplateResponse( 97 | request=request, name="page.html", context={"page": page} 98 | ) 99 | 100 | return router 101 | -------------------------------------------------------------------------------- /src/fastapi_blog/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "layout/base.html" %} 2 | {% block content %} 3 |

404

4 | {% endblock content %} -------------------------------------------------------------------------------- /src/fastapi_blog/templates/index.html: -------------------------------------------------------------------------------- 1 | {% set title = "Daniel Roy Greenfeld" %} 2 | {% extends "layout/base.html" %} 3 | {% block content %} 4 |

Recent Writings

5 | 6 | {% for post in recent_3 %} 7 | {% include "partials/_post_short.html" %} 8 | {% endfor %} 9 | 10 | {% if favorite_posts | length > 0 %} 11 |

Favorite Articles

12 | {% endif %} 13 | 14 | {% for post in favorite_posts %} 15 | {% include "partials/_post_short.html" %} 16 | {% endfor %} 17 | 18 | {% endblock content %} -------------------------------------------------------------------------------- /src/fastapi_blog/templates/layout/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% if metadata %} 4 | {{ metadata.title|default("Daniel Roy Greenfeld") }} 5 | {% elif title %} 6 | {{ title }} 7 | {% else %} 8 | Daniel Roy Greenfeld 9 | {% endif %} 10 | 11 | 12 | 13 | 56 | 57 | 58 |
59 | 60 | Daniel Roy Greenfeld 68 |

Daniel Roy Greenfeld

69 |

70 | 71 | About 72 | 73 | | 74 | 75 | Articles 76 | 77 | | 78 | 79 | Tags 80 | 81 |

82 |
83 |
84 | {% block content required %}{% endblock content %} 85 |
86 | 94 | 95 | -------------------------------------------------------------------------------- /src/fastapi_blog/templates/page.html: -------------------------------------------------------------------------------- 1 | {% extends "layout/base.html" %} 2 | {% block content %} 3 |

{{ page.metadata.title }}

4 | 5 | {% if page.metadata.description %} 6 |

{{ page.metadata.description }}

7 | {% endif %} 8 | 9 | {% if page.metadata.image %} 10 |

11 | {% endif %} 12 | 13 | {{ page.html|safe }} 14 | 15 | {% endblock content %} -------------------------------------------------------------------------------- /src/fastapi_blog/templates/partials/_post_short.html: -------------------------------------------------------------------------------- 1 | 2 |

{{ post.title }}

3 |

4 | {% if post.description %} 5 | {{ post.description }}
6 | {% endif %} 7 |
8 | {% if post.tags %} 9 | Tags: {% for tag in post.tags %} 10 | {{ tag }} 11 | {% endfor %} 12 | {% endif %} 13 |

14 |
-------------------------------------------------------------------------------- /src/fastapi_blog/templates/post.html: -------------------------------------------------------------------------------- 1 | {% extends "layout/base.html" %} 2 | {% block content %} 3 |

{{ post.title }}

4 | 5 | 6 | {{ post.content|safe }} 7 | 8 | {% if post.tags and post.tags | length > 0 %} 9 |

Tags: 10 | 11 | 12 | {% for tag in post.tags %} 13 | {{ tag }} 14 | {% endfor %} 15 |

16 | {% endif %} 17 | 18 | {% endblock content %} -------------------------------------------------------------------------------- /src/fastapi_blog/templates/posts.html: -------------------------------------------------------------------------------- 1 | {% extends "layout/base.html" %} 2 | {% block content %} 3 |

Articles

4 | 5 | {% for post in posts %} 6 | {% include "partials/_post_short.html" %} 7 | {% endfor %} 8 | {% endblock %} -------------------------------------------------------------------------------- /src/fastapi_blog/templates/tag.html: -------------------------------------------------------------------------------- 1 | {% extends "layout/base.html" %} 2 | {% block content %} 3 |

Tags: {{ tag }} ({{ posts|length() }})

4 | 5 | {% for post in posts %} 6 | {% include "partials/_post_short.html" %} 7 | {% endfor %} 8 | {% endblock content %} -------------------------------------------------------------------------------- /src/fastapi_blog/templates/tags.html: -------------------------------------------------------------------------------- 1 | {% extends "layout/base.html" %} 2 | {% block content %} 3 |

Tags

4 | 5 | {% for tag, count in tags.items() %} 6 | {{ tag }} ({{ count }}) 7 | {% endfor %} 8 | {% endblock content %} -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydanny/fastapi-blog/2ab05c74001a79ad22e05a9c5372733cc6a885ca/tests/__init__.py -------------------------------------------------------------------------------- /tests/examples/defaults.py: -------------------------------------------------------------------------------- 1 | import fastapi_blog 2 | from fastapi import FastAPI 3 | from fastapi.staticfiles import StaticFiles 4 | 5 | 6 | app = FastAPI() 7 | app = fastapi_blog.add_blog_to_fastapi(app) 8 | app.mount("/static", StaticFiles(directory="static"), name="static") 9 | 10 | 11 | @app.get("/") 12 | async def index() -> dict: 13 | return { 14 | "message": "Check out the blog at the URL", 15 | "url": "http://localhost:8000/blog", 16 | } 17 | -------------------------------------------------------------------------------- /tests/examples/favorite_post_ids.py: -------------------------------------------------------------------------------- 1 | import fastapi_blog 2 | from fastapi import FastAPI 3 | 4 | 5 | favorite_post_ids = { 6 | "code-code-code", 7 | "thirty-minute-rule", 8 | "2023-11-three-years-at-kraken-tech", 9 | } 10 | 11 | app = FastAPI() 12 | app = fastapi_blog.add_blog_to_fastapi(app, favorite_post_ids=favorite_post_ids) 13 | 14 | 15 | @app.get("/") 16 | async def index() -> dict: 17 | return { 18 | "message": "Check out the blog at the URL", 19 | "url": "http://localhost:8000/blog", 20 | } 21 | -------------------------------------------------------------------------------- /tests/examples/modified_all.py: -------------------------------------------------------------------------------- 1 | import fastapi_blog 2 | import jinja2 3 | from fastapi import FastAPI 4 | 5 | 6 | prefix = "content" 7 | django_style_jinja2_loader = jinja2.ChoiceLoader( 8 | [ 9 | jinja2.FileSystemLoader("templates"), 10 | jinja2.PackageLoader("fastapi_blog", "templates"), 11 | ] 12 | ) 13 | 14 | app = FastAPI() 15 | app = fastapi_blog.add_blog_to_fastapi( 16 | app, prefix=prefix, jinja2_loader=django_style_jinja2_loader 17 | ) 18 | 19 | 20 | @app.get("/") 21 | async def index() -> dict: 22 | return { 23 | "message": "Check out the blog at the URL", 24 | "url": f"http://localhost:8000/{prefix}", 25 | } 26 | -------------------------------------------------------------------------------- /tests/examples/pages/about.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "About Daniel Roy Greenfeld" 3 | description: "A little bit of background about Daniel Roy Greenfeld" 4 | author: "Daniel Roy Greenfeld" 5 | --- 6 | 7 | [TOC] 8 | 9 | ## Intro about me 10 | 11 | ```python 12 | import random 13 | 14 | for i in range(10): 15 | print(i) 16 | 17 | ``` 18 | 19 | I'm probably best known as "[pydanny](https://www.google.com/search?q=pydanny)", one of the authors of Two Scoops of Django. 20 | 21 | I love to hang out with my [wife](https://audrey.feldroy.com/), play with my [daughter](/tags/uma), do [Brazilian Jiu-Jitsu](https://academyofbrazilianjiujitsu.com/), write [books](/books), and read books. I work at [Kraken Tech](https://kraken.tech/), a company that is part of the sustainable global energy [Octopus Energy Group](https://octopusenergy.group/) working to address [global climate change](/tags/climate-change). 22 | 23 | - [Mastodon](https://fosstodon.org/@danielfeldroy) 24 | - [LinkedIn](https://www.linkedin.com/in/danielfeldroy/) 25 | - [Twitter](https://twitter.com/pydanny) 26 | 27 | ## About this site 28 | 29 | This site is written in: 30 | 31 | - Python 32 | - FastAPI 33 | - fastapi-blog 34 | - Sakura minimal CSS framework 35 | - Markdown 36 | - Vanilla HTML 37 | -------------------------------------------------------------------------------- /tests/examples/posts/2023-01-configuring-sphinx-auto-doc-with-django.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-01-19T22:20:50.52Z" 3 | published: true 4 | tags: 5 | - octopus 6 | - kraken 7 | - python 8 | - django 9 | - howto 10 | time_to_read: 10 11 | title: Configuring Sphinx Auto-Doc with projects having Django dependencies 12 | description: How to make it so projects with Django as a dependency benefit from Sphinx's auto-documentation features. 13 | image: /images/cute-octo-plushie-200x200.png 14 | twitter_image: /images/cute-octo-plushie-200x200.png 15 | og_url: https://daniel.feldroy.com/posts/2023-01-configuring-sphinx-auto-doc-with-django 16 | --- 17 | 18 | How to make it so projects with Django as a dependency benefit from Sphinx's auto-documentation features. 19 | 20 | # The Problem 21 | 22 | I want to be able to document open source packages with Sphinx (ex. [xocto](https://github.com/octoenergy/xocto)) and have Sphinx automatically document the Django helpers. This isn't quite the same as documenting a Django project, so I wasn't sure if the otherwise awesome [sphinxcontrib-django](https://sphinxcontrib-django.readthedocs.io/) would be the right tool. Fortunately, there's a solution that doesn't require any additional packages. 23 | 24 | # The Solution 25 | 26 | ## Configuration 27 | 28 | First, in the Sphinx docs folder, create a file called `django_settings.py` and add the following: 29 | 30 | ```python 31 | """ 32 | Minimal file so Sphinx can work with Django for autodocumenting. 33 | 34 | Location: /docs/django_settings.py 35 | """ 36 | 37 | # INSTALLED_APPS with these apps is necessary for Sphinx to build 38 | # without warnings & errors 39 | # Depending on your package, the list of apps may be different 40 | INSTALLED_APPS = [ 41 | "django.contrib.auth", 42 | "django.contrib.contenttypes", 43 | ] 44 | ``` 45 | 46 | Next, at the top of Sphinx's `conf.py`, add the following: 47 | 48 | ```python 49 | # docs/conf.py 50 | import os 51 | import sys 52 | 53 | import django 54 | 55 | # Note: You may need to change the path to match 56 | # your project's structure 57 | sys.path.insert(0, os.path.abspath("..")) # For discovery of Python modules 58 | sys.path.insert(0, os.path.abspath(".")) # For finding the django_settings.py file 59 | 60 | # This tells Django where to find the settings file 61 | os.environ["DJANGO_SETTINGS_MODULE"] = "django_settings" 62 | # This activates Django and makes it possible for Sphinx to 63 | # autodoc your project 64 | django.setup() 65 | ``` 66 | 67 | ## Usage 68 | 69 | In one of your documentation files, perhaps `docs/localtime.rst`: 70 | 71 | ```plaintext 72 | 73 | .. automodule:: xocto.localtime 74 | :members: 75 | :undoc-members: 76 | :show-inheritance: 77 | ``` 78 | 79 | Or if you are using [myst-parser](https://myst-parser.readthedocs.io/) to use Markdown with Sphinx. In this case, the file would be at `docs/localtime.md`: 80 | 81 | ````markdown 82 | ```{eval-rst} 83 | .. automodule:: xocto.localtime 84 | :members: 85 | :undoc-members: 86 | :show-inheritance: 87 | ``` 88 | ```` 89 | 90 | # Come and work with me 91 | 92 | My employer is hiring! We've got dozens of roles and hundreds of positions open across multiple fields in several countries (USA, UK, Germany, France, Australia, Japan, Italy, Spain, and more). Our mission is to address climate change through decarbonization, electrification, and being good citizens. We're a growing group of companies with a big mission, and we're looking for people who want to make a difference. Check out our [careers page](https://octopus.energy/careers/) to see if what we do excites you and our [open roles page](https://octopus.energy/careers/join-us/) to discover if there's a role that you find interesting. 93 | 94 | [![](/images/cute-octo-plushie-200x200.png)](https://octopus.energy/careers/) 95 | -------------------------------------------------------------------------------- /tests/examples/posts/2023-01-converting-markdown-headers-to-checklist.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-01-27T23:45:00.00Z" 3 | published: true 4 | slug: converting-markdown-headers-to-checklist 5 | tags: 6 | - python 7 | - nodejs 8 | - javascript 9 | - markdown 10 | - howto 11 | time_to_read: 5 12 | title: Converting Markdown Headers to Checklist 13 | description: For those times when you write out a long markdown document that you want to convert to a checklist. 14 | type: post 15 | image: /images/superhero-markdown-checklist.png 16 | --- 17 | 18 | For those times when you write out a long markdown document that you want to convert to a checklist. 19 | 20 | # Converting Markdown Headers to Checklist 21 | 22 | Python: 23 | 24 | ```python 25 | response = [] 26 | with open("sample.md") as f: 27 | lines = f.readlines() 28 | for line in lines: 29 | if line.startswith("#"): 30 | indentation = line.count("#") - 1 31 | newline = f"{' ' * 2 * indentation}- [ ]{line.replace('#', '')}" 32 | response.append(newline) 33 | 34 | with open("checklist.md", "w") as f: 35 | f.writelines(response) 36 | ``` 37 | 38 | JavaScript: 39 | 40 | ```javascript 41 | const fs = require("fs"); 42 | 43 | function MarkdownHeadersToChecklist(markdown) { 44 | const lines = markdown.split("\n"); 45 | const headers = lines.filter((line) => line.startsWith("#")); 46 | let checklist = []; 47 | for (const header of headers) { 48 | const indentation = header.split("#").length - 1; 49 | const spacer = " ".repeat(2 * indentation); 50 | const newline = `${spacer}- [ ]${header.replace("#", "")}`; 51 | checklist.push(newline); 52 | } 53 | return checklist.join("\n"); 54 | } 55 | 56 | const markdown = fs.readFileSync("sample.md", "utf8"); 57 | 58 | const checklist = MarkdownHeadersToChecklist(markdown); 59 | 60 | fs.writeFileSync("checklist.md", checklist); 61 | ``` 62 | 63 | ![Converting Markdown Headers to Checklist](/images/superhero-markdown-checklist.png) 64 | -------------------------------------------------------------------------------- /tests/examples/posts/2023-01-practice-python-web-projects.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-01-14T22:20:50.52Z" 3 | published: true 4 | tags: 5 | - python 6 | - django 7 | - fastapi 8 | - flask 9 | time_to_read: 2 10 | title: Practice Python Web Projects 11 | description: Python web projects taken from my personal history to practice on to improve your skills. 12 | image: /images/Practice-Python-Web-Projects.png 13 | og_url: https://daniel.feldroy.com/posts/2023-01-practice-python-web-projects 14 | --- 15 | 16 | I firmly believe that substantial improvement in any skill can only be achieved through [practice](https://daniel.feldroy.com/posts/code-code-code). With that in mind, I have compiled a list of projects I have built over the years to practice my skills with web frameworks. I hope that these projects, which have proven effective for me in learning frameworks, will also assist you in your growth. 17 | 18 | These projects are framework-agnostic and can be implemented using [Django](https://djangoproject.com), [Flask](https://flask.palletsprojects.com/), [FastAPI](https://fastapi.tiangolo.com/), or any other framework of your choice. While some projects may be better suited for certain frameworks, all of them provide valuable practice. All of these projects can be completed using either the default frontend templating system or a frontend framework like React or Vue. 19 | 20 | # The Projects 21 | 22 | ## 1. API to Data 23 | 24 | Creating a web application to present data can be a lot of work. An alternative option is to build an API that returns the data in a well-structured format. This project is a great opportunity to practice using Django REST Framework, Flask, or FastAPI. In fact, it is my understanding that FastAPI was specifically designed for this purpose. Nevertheless, this project can be implemented using any framework and is a valuable practice. 25 | 26 | Bonus points: Don't create the data that you are working with. Instead, request for data from someone else. This will not only give you practice in creating an API but also in working with data provided by others, which is an important skill to have. 27 | 28 | ## 2. Show me the charts 29 | 30 | Graphviz is a powerful command-line tool for generating graphical representations of data. This project involves creating a simple API that takes a Graphviz file as input, and returns a PNG image of the generated graph. The API can be built using HTML form that accepts Graphviz dot format, saves it as a file on the server, and renders the resulting image for the user to view. 31 | 32 | I have built this project several times in the past, with my first implementation in 2006 using web.py. It is a great project to practice file handling and working with command-line tools in a web environment. 33 | 34 | Bonus points: Saving changes to files over time so the user can go back and see what they've created in the past. 35 | 36 | ## 3. Wiki 37 | 38 | Build a wiki with the following requirements: 39 | 40 | - Users can create pages, edit pages, and delete pages 41 | - Content is stored as Markdown and served as HTML 42 | - Persistence can be a database or a set of markdown files 43 | - Slugs for pages are automatically generated from the title 44 | - If someone tries to create a page with a slug that already exists, they are redirected to the existing page 45 | - Any and all changes to content pages have to be tracked in an audit log viewable by any user 46 | - Include search functionality 47 | 48 | Bonus points: Add the ability to report pages as spam, and have a moderation queue for pages that have been reported. 49 | 50 | ## 4. Social Network 51 | 52 | Build a social network with the following features: 53 | 54 | - User registration 55 | - Event stream like Twitter, Facebook, or Mastodon 56 | - Ability to become friends with other users, friendship comes with an approval process. Friendship allows DMs 57 | - Ability to follow other users' stream, no permission needed 58 | - Direct messaging system 59 | 60 | Historical note: My first professional Django Project was this project, it was a great way to learn the framework. It resulted in the creation of what is now known as [django-crispy-forms](https://pypi.org/project/django-crispy-forms/). Alas, the social network we built, "Spacebook", hasn't been online for over a decade. 61 | 62 | Bonus points: Add public and private groups for people who share a similar interest. 63 | 64 | ## 5. Turn-based game 65 | 66 | Build a turn-based game that uses a web interface. It can be as simple as tic-tac-toe or something more sophisticated with multiple players. 67 | 68 | Bonus points: Use websockets to notify players when it's their turn. 69 | 70 | ## 6. Advert-free story-lite recipe site 71 | 72 | Searching for recipes can be frustrating, often requiring one to sift through lengthy personal anecdotes and ads before finding the desired information. Build a website that allows users to easily search for recipes and presents only the recipe without any additional distractions such as ads or personal stories. Requirements: 73 | 74 | - Visitors can search for recipes 75 | - Users can create their own accounts 76 | - Users can create their own recipes 77 | - The description/history of a recipe has to be limited to 500 characters 78 | - Provide an API for searching, listing, and examining recipes 79 | 80 | Bonus points: Provide ability for users to upload images for recipes. These images need to be stored in a CDN and not on the server. 81 | 82 | ## 7. Third-party Package 83 | 84 | > _You don't really know a framework or its configuration until you build an installable package deployed to PyPI._ 85 | 86 | > _-- Someone at the 2009 PyCon US sprint_ 87 | 88 | Requirements: 89 | 90 | - Must be installable via pip 91 | - Must be usable with Django, Flask, or FastAPI 92 | - Includes extensive documentation 93 | - Docs hosted on Read the Docs or other documentation hosting service 94 | 95 | This is a great project to practice with Django, Flask, or FastAPI. 96 | 97 | Bonus points: Package can be used with all three web frameworks: Django, Flask, and FastAPI. 98 | 99 | ![https://daniel.feldroy.com/posts/2023-01-practice-python-web-projects](/images/Practice-Python-Web-Projects.png) 100 | -------------------------------------------------------------------------------- /tests/examples/posts/2023-01-resolutions.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-01-15T23:45:00.00Z" 3 | published: true 4 | slug: resolutions-2023 5 | tags: 6 | - family 7 | - uma 8 | - audrey 9 | - resolutions 10 | - octopus 11 | - kraken 12 | - climate-change 13 | time_to_read: 5 14 | title: Resolutions for 2023 15 | description: My resolutions for 2023 16 | type: post 17 | image: /images/Resolutions-2023.png 18 | --- 19 | 20 | # Resolutions for 2023 21 | 22 | ## Continue to work on saving the planet 23 | 24 | Since November of 2020 I've been at Octopus Energy using my skills and talent to address climate change. I plan to continue that effort. You can read my thoughts on why I'm so dedicated to our mission [here](/tags/octopus). 25 | 26 | ## Continue my fitness journey 27 | 28 | In 2022 I joined a VR Beat Saber club and started going to a chiropractor. These helped a shoulder injury recover enough for me to start weightlifting and Brazilian Jiu-Jitsu again. I want to continue and fit in my old clothes again. 29 | 30 | In 2023 I plan to continue my fitness journey. 31 | 32 | ## Publish at least book 33 | 34 | Last I published a new book was 2020. Be it fiction or non-fiction I want to put out a new book. 35 | 36 | ## Compete in a hackathon 37 | 38 | The last I participated in a hackathon was 2021. I love the feeling of competition and drive to launch fast. I'm hoping to have the opportunity to compete in 2023. 39 | 40 | ## Go someplace new 41 | 42 | Be it here in Southern California, another state, or even another country, I want to explore someplace new. I'm hoping to go to the Grand Canyon in 2023 but I'll take anything. 43 | 44 | ## Learn new things 45 | 46 | I'm of the opinion that if you're not learning, you're career is ending. I want to continue to learn new things. In 2023 I plan to learn more about Python, Rust, and JavaScript. While I'm at it, I want to deepen my BJJ knowledge. 47 | 48 | ## Be a good parent 49 | 50 | Going into my daughter's 4th year I rededicate myself toward being a good father. 51 | 52 | ![2023 resolutions](/images/Resolutions-2023.png) 53 | -------------------------------------------------------------------------------- /tests/examples/posts/2023-02-programming-languages-ive-learned.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-02-24T23:45:00.00Z" 3 | published: true 4 | slug: programming-language-ive-learned 5 | tags: 6 | - blog 7 | - python 8 | - javascript 9 | - coldfusion 10 | - nodejs 11 | - foxpro 12 | - java 13 | - technology 14 | time_to_read: 1 15 | title: Programming languages I've learned 16 | description: Starting as an adolescent I've been learning programming languages. Here's a list of the ones I've learned. 17 | type: post 18 | --- 19 | 20 | Matt Harrison [asked in a tweet](https://twitter.com/__mharrison__/status/1628062560091090944) what my programming history was, so here's a list of the programming languages I've learned. 21 | 22 | ## AppleBasic (ancient history) 23 | 24 | My first computer was an Apple ][+. I learned AppleBasic from a book that came with the computer. I wrote oodles of text-based games. The most popular was a Star Trek battle clone that was well-appreciated in 10th grade. 25 | 26 | ## FoxPro (1997-2000) 27 | 28 | I learned FoxPro at my first professional software development job. I liked FoxPro for DOS and still have a soft spot for CLI dbase-style languages. The same can't be said for Visual FoxPro for Windows, which never clicked for me. 29 | 30 | ## JavaScript (1998+) 31 | 32 | Originally I just copy/pasted scripts from sites and books. I struggled with it and the DOM until encountering JQuery in 2008 plus reading "JavaScript: The Good Parts" the same year. That helped and I learned to enjoy the language. 33 | 34 | ## Perl (1999) 35 | 36 | I spent about six months coding with Perl. I enjoyed it, especially the regular expressions that have served me so well over the years. The challenge was that in my self-teaching as a junior developer, I didn't think about maintainability. When I had to go back to maintain stuff I wrote I was lost, got frustrated, and left it behind. Probably not fair to Perl, but it is what it is. 37 | 38 | ## ColdFusion (2000-2006) 39 | 40 | This was popular in US Federal government circles. In a way, it was like a closed-source PHP, with lots of idiosyncrasies. The biggest problem the language had was people - for whatever reason it attracted those who coded via cargo cult copy/paste programming and generally bad software design who refused to change or adapt. Which sucked because coming in to maintain someone else's code was a nightmare. Still, I have a soft spot for ColdFusion because it helped me identify that I preferred more dynamic languages over static ones. 41 | 42 | ## VBScript (2000-2001) 43 | 44 | This was one of Microsoft's abortive attempts to hijack JavaScript. I liked it more than the JavaScript of the time, but as it only worked on Internet Explorer that limited its usefulness. 45 | 46 | ## Java (2000-2006) 47 | 48 | Java during the time I used it was designed to be for the "Enterprise". Which meant tons of boilerplate code, lots of XML, and a general lack of fun. The heavyweight of Enterprise Java was a huge turnoff. I struggled with it so much that I developed an intolerance for strongly typed languages (from which I've only recovered in the last few years). I admit my opinion of Java is biased, I'm sure it would be awesome if I dug into the modern version. 49 | 50 | ## Python (2005+) 51 | 52 | This is the programming language and community that changed my life. Python fits into my brain and it's my go-to language for all kinds of scripts. 53 | 54 | ## Lua (2011-2012) 55 | 56 | For a brief time, I did Lua and enjoyed it. If I were into game scripting I'd probably still be using it. 57 | 58 | ## Modern NodeJS (2018+) 59 | 60 | In 2018 I finally knuckled down and learned the ways of NodeJS. Much as I love Python, knowing NodeJS has been a game-changer for me. Arrow functions are fun and I wish Python had a decent analog. 61 | 62 | ## C# (2020+) 63 | 64 | In 2020 I started to play with the Unity game engine. C# clicked for me surprisingly fast and it was my favorite part of writing games. It's part of why I wonder if I should revisit Java since the languages are so similar. 65 | 66 | ## Go (2020) 67 | 68 | I did some Golang in late 2020. I liked it and saw the virtues, especially the concurrency model. I just don't have any use cases for it that my other tools don't solve. 69 | 70 | ## JSX (2021+) 71 | 72 | To build out a website for work I learned the fundamentals ReactJS and JSX over a weekend. This blog is my first project, which I continue to extend and modify. I like noodling with JSX, and I can see why it has become so popular. While arguably not a programming language, it's clear it is a huge productivity boost for me and others. It's fun to write and that's what counts. 73 | 74 | ## TypeScript (2022+) 75 | 76 | Kicking and screaming I've been dragged into the TypeScript world. I know professional FE devs love it, but I'm more partial to NodeJS because I feel like I'm coding, not playing with types. That said, it is kind of relaxing figuring out the types. Where I think TypeScript fails is I haven't seen any dominant libraries built around its typing features. For example, in Python we have pydantic and FastAPI, which are powered by type annotations. I haven't seen anything like that for TypeScript. If you know of anything like it, point me to it! 77 | 78 | ## Rust (2022+) 79 | 80 | In December of 2022, I started to learn Rust. Its use in WASM is fascinating, as is the performance boost and the sophistication of the compiler. If only I had a business reason to practice it, but for now it's just a hobby tool. 81 | -------------------------------------------------------------------------------- /tests/examples/posts/2023-04-aws-requests-auth.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-04-20T23:45:00.00Z" 3 | published: true 4 | slug: 2023-04-aws-requests-auth 5 | tags: 6 | - python 7 | - howto 8 | - aws 9 | - TIL 10 | time_to_read: 1 11 | title: AWS Requests Auth 12 | description: AWS signature version 4 signing process for the python requests module. 13 | type: post 14 | image: /logos/til-1.png 15 | twitter_image: /logos/til-1.png 16 | --- 17 | 18 | David Muller, the author of [Intuitive Python](https://pragprog.com/titles/dmpython/intuitive-python/) pointed me at this [handy snippet of code](https://github.com/boto/botocore/issues/1784#issuecomment-659132830). Thanks [Richard Boyd](https://github.com/richardhboyd)! 19 | 20 | ```python 21 | import boto3 22 | from botocore.auth import SigV4Auth 23 | from botocore.awsrequest import AWSRequest 24 | import requests 25 | 26 | session = boto3.Session() 27 | credentials = session.get_credentials() 28 | creds = credentials.get_frozen_credentials() 29 | 30 | def signed_request(method, url, data=None, params=None, headers=None): 31 | request = AWSRequest(method=method, url=url, data=data, params=params, headers=headers) 32 | # "service_name" is generally "execute-api" for signing API Gateway requests 33 | SigV4Auth(creds, "service_name", REGION).add_auth(request) 34 | return requests.request(method=method, url=url, headers=dict(request.headers), data=data) 35 | 36 | def main(): 37 | url = f"my.url.example.com/path" 38 | data = {"environmentId": self._environment_id} 39 | headers = {'Content-Type': 'application/x-amz-json-1.1'} 40 | response = signed_request(method='POST', url=url, data=data, headers=headers) 41 | 42 | if __name__ == "__main__": 43 | main() 44 | ``` 45 | -------------------------------------------------------------------------------- /tests/examples/posts/2023-04-cookiecutter-options-pattern.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-04-19T23:45:00.00Z" 3 | published: true 4 | slug: 2023-04-cookiecutter-options-pattern 5 | tags: 6 | - cookiecutter 7 | - howto 8 | - python 9 | - tech.octopus.energy 10 | time_to_read: 1 11 | title: Cookiecutter Options Pattern 12 | description: A technique I've used for years yet often forget. Placing it here for easy reference. 13 | type: post 14 | --- 15 | 16 | A way to simplify complicated Cookiecutters is the Options Pattern. If I step away from Cookiecutter for any duration, I forget about it. Thanks to [Mark Patricio](https://github.com/sammaritan12) for the reminder! 17 | 18 | # Complex example we'll simplify: 19 | 20 | Take a look at the arguments code: 21 | 22 | ```javascript 23 | // cookiecutter.json 24 | { 25 | "project_name": "thing", 26 | "volume": ["low", "medium", "high"] 27 | } 28 | ``` 29 | 30 | And the template code (line breaks added so it fits on computer/tablet/mobile screens): 31 | 32 | ```django 33 | # thing/config.py 34 | # Volume: {% if cookiecutter.volume == "low" %}low 35 | {% elif cookiecutter.volume == "medium" %}medium 36 | {% elif cookiecutter.volume == "high" %}high{% endif %} 37 | VOLUME_SETTING = {% if cookiecutter.volume == "low" %}5 38 | {% elif cookiecutter.volume == "medium" %}10 39 | {% elif cookiecutter.volume == "high" %}15{% endif %} 40 | ``` 41 | 42 | # Simplified example: 43 | 44 | First the arguments code, which now contains a `__volume_options` field. 45 | 46 | ``` javascript 47 | // cookiecutter.json 48 | { 49 | "project_name": "thing", 50 | "volume": ["low", "medium", "high"] 51 | "__volume_options": { 52 | "low": 5, 53 | "medium": 10, 54 | "high": 15 55 | } 56 | } 57 | ``` 58 | 59 | By prefixing the `__volume_options` with double underscores, the field is now a [private variable](https://cookiecutter.readthedocs.io/en/stable/advanced/private_variables.html). This means it isn't displayed to the user during the arguments stage. Rather, it is just passed into the context of the template. 60 | 61 | This `__volume_options` field is also a [dict variable](https://cookiecutter.readthedocs.io/en/stable/advanced/dict_variables.html). It is a key/value type of object. This is important, as the Jinja renderer of Cookiecutter allows us to fetch the value of the pair by calling the key. 62 | 63 | Having `__volume_options` built around these two features allows us to simplify the template to the point where we don't need to add linebreaks to make it legible: 64 | 65 | ```django 66 | # thing/config.py 67 | # Volume: {{ cookiecutter.volume }} 68 | VOLUME_SETTING = {{ cookiecutter.__volume_options[cookiecutter.volume] }} 69 | ``` 70 | 71 | # Simplified result for easy reference 72 | 73 | ``` javascript 74 | // cookiecutter.json 75 | { 76 | "project_name": "thing", 77 | "volume": ["low", "medium", "high"] 78 | "__volume_options": { 79 | "low": 5, 80 | "medium": 10, 81 | "high": 15 82 | } 83 | } 84 | ``` 85 | 86 | ```django 87 | # thing/config.py 88 | # Volume: {{ cookiecutter.volume }} 89 | VOLUME_SETTING = {{ cookiecutter.__volume_options[cookiecutter.volume] }} 90 | ``` 91 | 92 | 93 | 94 | # Extra benefits to this approach 95 | 96 | This puts more of the data into `cookiecutter.json` rather than the templates. Discovery of values is easier, smoothing the path for maintenance or new features. 97 | -------------------------------------------------------------------------------- /tests/examples/posts/2023-04-my-fitness-journey.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-04-26T10:00:00.00Z" 3 | published: true 4 | slug: my-2023-fitness-journey 5 | tags: 6 | - fitness 7 | - bjj 8 | - martial arts 9 | time_to_read: 1 10 | title: My 2023 Fitness Journey 11 | description: At the start of the year I had started to get back into shape. This article documents what I'm doing and how well it has gone. 12 | type: post 13 | --- 14 | 15 | # Losing my fitness 16 | 17 | As the pandemic went on I struggled with fitness and weight gain issues. I had injured both knees right before we went into quarantine. I'm also much more consistent working out in groups or with a partner than by myself. So I lost muscle mass as I gained weight. 18 | 19 | This caused complications that made my life less pleasant. Walking hurt and going up and down stairs was a laborious chore. Most of my clothes no longer fit. Old injuries like my left shoulder flared up. Cartwheels had become impossible. 20 | 21 | # Ending 2022 22 | 23 | 2022 ended with me being at the heaviest I can remember. Most of my clothes didn't fit. 24 | 25 | On the other hand, I had been doing regular exercise for months, [Beat Saber](https://en.wikipedia.org/wiki/Beat_Saber) online with co-workers for months. And thanks to the vaccination of me and my family, a couple of months of Brazilian Jiu-Jitsu ([BJJ](https://en.wikipedia.org/wiki/Brazilian_jiu-jitsu)). 26 | 27 | My knees were getting better slowly, but they ached all the time. My weight made the healing of my knees slow and tedious. 28 | 29 | # 2023 New Year's Resolutions #2 and #7 30 | 31 | [Resolution #2](https://daniel.feldroy.com/posts/2023-01-resolutions) was to restart my old fitness path, and #7 was to be a better father. The weight and the knee pain meant I couldn't play with my daughter at the intensity I wanted. 32 | 33 | So to fulfill those resolutions I put together a plan. 34 | 35 | # The Plan 36 | 37 | The plan was as simple as possible. 38 | 39 | 1. Exercise more 40 | 2. Eat better 41 | 42 | ## 1: Exercise more 43 | 44 | I try to get in a serious workout every weekday, with Saturday and Sunday for rest. 45 | 46 | My preferred workout method is BJJ. To avoid re-injuring my knees or left shoulder I refuse to do certain techniques, and in sparring tap sooner rather than later. I do the martial art for sport and fitness and fun, not to prove I'm a "tough guy". 47 | 48 | On the days I am too tired or busy to do BJJ, I lift weights with my [wife](https://audrey.feldroy.com/). Or put on the headset for Beat Saber or other VR exercise games. I want to get more into indoor rock climbing as it is fun and is a nice corollary for BJJ. 49 | 50 | We've been in London since early March without an automobile so I get in a good amount of walking and a fair bit of cycling. Speaking of London, weekends are spent exploring the city or chasing after my daughter Uma at playgrounds. So my rest days do include at least light exertion. 51 | 52 | ### Where do I train BJJ? 53 | 54 | Having hunted around in both Los Angeles and London, these are the places I would recommend for the instruction and safety of students: 55 | 56 | - London (Central): [Grand Union BJJ](https://grandunionbjj.com/) - Where I train right now 57 | - Los Angeles: [Academy of Brazilian Jiu-Jitsu](https://academyofbrazilianjiujitsu.com/) - Fantastic coaching 58 | 59 | ## 2. Eat better 60 | 61 | My diet looked like this: 62 | 63 | - No more soda 64 | - No snacks between meals 65 | - No desserts 66 | - Eat proteins 67 | - Don't worry about fats 68 | - Avoid simple carbs like bread and cereals 69 | - Eat less 70 | 71 | Predictability I was hungry all the time, making it hard to sleep or focus. Irritation and fatigue plagued me. Of course, my workouts suffered. 72 | 73 | ## 2a. Eating better improved 74 | 75 | As suggested by one of my BJJ coaches, I added an enormous amount of leafy green vegetables to my diet. That's how I would ingest carbohydrates to provide the energy 76 | 77 | I got used to eating bowls of baby kale, baby spinach, broccoli, and other things for breakfast. Every meal I ate green veggies as a side. Cooked or raw. If I got really hungry at night I could snack on more leafy greens. My energy levels shot up and the constant hunger faded. 78 | 79 | My current diet is: 80 | 81 | - No more soda 82 | - No snacks between meals 83 | - No desserts 84 | - Eat proteins 85 | - Don't worry about fats 86 | - Eat tons of leafy greens 87 | - Cheat now and then for a bite or two with a dessert or starchy side 88 | 89 | 90 | # Progress 91 | 92 | I've lost about 25 pounds or 11.3 kilograms while putting on muscle mass. The result is I can wear stuff in my wardrobe I haven't been able to wear in years. My shoulder and knees are improving, the harsh pain replaced by dull aches that is slowly improving. 93 | 94 | I can do cartwheels again, but am sticking to gentler [Capoeira movements](https://en.wikipedia.org/wiki/List_of_capoeira_techniques) like `Rolê`, `Aú Fechado`, and `Aú Aberto`. 95 | 96 | # What's next? 97 | 98 | Get more into rock climbing as it also really helps me with acrophobia. 99 | 100 | I am on the road to losing another 10 pounds, or about 3 kilograms. I plan to maintain that final weight while building strength and recovering further from injury. 101 | -------------------------------------------------------------------------------- /tests/examples/posts/2023-06-bjj-training-tips.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-06-16T10:00:00.00Z" 3 | published: true 4 | slug: bjj-training-tips 5 | tags: 6 | - bjj 7 | - martial arts 8 | time_to_read: 1 9 | title: BJJ Training Tips 10 | description: I started Brazilian Jiu-Jitsu in early November 2022. Against most other white belts I usually can't be submitted and have landed a few submissions of my own. I'm not big or strong so I use these rules given by my coaches and fellow players of the art. 11 | --- 12 | 13 | I started Brazilian Jiu-Jitsu in early November 2022. Against most other white belts I usually can't be submitted and have landed a few submissions of my own. I'm not big or strong so I use these rules given by my coaches and fellow players of the art. 14 | 15 | - Tap early and tap often. Better to train tomorrow then get hurt and be out for a month 16 | - Before you spar with someone, tell your opponent about any injuries 17 | - Go for position and transition instead of submission 18 | - If you lose: smile, and show respect 19 | - If someone smothers you with pressure and you can't get out, don't let it get to your head. Tap and move on with a smile. Part of the BJJ journey is learning how to deal with discomfort 20 | - If you win: show respect and humility 21 | - Ask questions 22 | - No matter how tired you get, if it's sparring time go as much as you can. The best way to build grappling endurance is get used to it 23 | - If someone's a jerk on the mat, unless they apologize don't train with them again 24 | - Don't be a jerk on the mat, look out for your opponent's welfare 25 | - Don't keep track of wins and losses 26 | - Wash your gi and rashgaurds at the end of your training day 27 | - Choose an accessory fitness method and do it regularly. I do weight lifting and a tiny bit of climbing, lots of others train in yoga. Not just because it might improve your BJJ, but it can help prevent injury and postural issues 28 | 29 | Most of these tips I got from my coaches Todd, Ben, and Ellen at [Academy of Brazilian Jiu-Jitsu](https://academyofbrazilianjiujitsu.com/) in Los Angeles, but I also got good advice at [Grand Union BJJ](https://grandunionbjj.com/) in London. 30 | -------------------------------------------------------------------------------- /tests/examples/posts/2023-06-converting-from-bleach-to-nh3.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-06-09T23:45:00.00Z" 3 | published: true 4 | slug: 2023-06-converting-from-bleach-to-nh3 5 | tags: 6 | - howto 7 | - python 8 | - rust-lang 9 | time_to_read: 1 10 | title: Converting from bleach to nh3 11 | description: Bleach is deprecated, here's how to come close to replicating bleach.clean() with no arguments with nh3. 12 | type: post 13 | --- 14 | 15 | [Bleach is deprecated](https://github.com/mozilla/bleach/issues/698), here's how to come close to replicating `bleach.clean()` using the [nh3](https://github.com/messense/nh3) version of `.clean()`. 16 | 17 | ```python 18 | import nh3 19 | 20 | def clean_string(string: str) -> str: 21 | # The arguments below being passed to `nh3.clean()` are 22 | # the default values of the `bleach.clean()` function. 23 | return nh3.clean( 24 | string, 25 | tags={ 26 | "a", 27 | "abbr", 28 | "acronym", 29 | "b", 30 | "blockquote", 31 | "code", 32 | "em", 33 | "i", 34 | "li", 35 | "ol", 36 | "strong", 37 | "ul", 38 | }, 39 | attributes={ 40 | "a": {"href", "title"}, 41 | "abbr": {"title"}, 42 | "acronym": {"title"}, 43 | }, 44 | url_schemes={"http", "https", "mailto"}, 45 | link_rel=None, 46 | ) 47 | ``` 48 | 49 | The big difference is unlike the safing of HTML done by bleach, nh3 removes the offending tags altogether. Read the comments below to see what this means. 50 | 51 | Results: 52 | 53 | ```python 54 | >>> input_from_user = """ 55 | 56 | I\'m not trying to XSS you Link 57 | """ 58 | >>> 59 | >>> # By default, bleach version safes the HTML 60 | >>> # rather than remove the tags altogether. 61 | >>> bleach.clean(input_from_user) 62 | '<img src="">I\'m not trying to XSS you Link' 63 | >>> 64 | >>> # In contrast, nh3 removes the offending tags entirely 65 | >>> # while also preserving whitespace. 66 | >>> clean_string(input_from_user) 67 | '\n\nI\'m not trying to XSS you Link\n' 68 | ``` 69 | 70 | # Advantages of switching to nh3 are: 71 | 72 | 1. nh3 is actively maintained, bleach is officially deprecated. 73 | 2. I believe the nh3 technique of stripping tags rather than allowing safing is more secure. The idea of safing is great, but I've always wondered if a creative attacker could find a way to exploit it. So I think it is better to remove the offending tags altogether. 74 | 3. The preservation of whitespace is really useful for preserving content submitted in a textarea. This is especially true for Markdown content. 75 | 4. nh3 is a binding to the [rust-ammonia project](https://github.com/rust-ammonia/ammonia). They claim a 15x speed increase over bleach's binding to the html5lib project. Even if that is a 3x exaggeration, that's still a 5x speed increase. 76 | 77 | # nh3 + Django 78 | 79 | If you're coding in Django, you owe it to yourself and all your users to read Adam Johnson's [fantastic article on using nh3 with Django](https://adamj.eu/tech/2023/12/13/django-sanitize-incoming-html-nh3/). 80 | 81 | # Update 82 | 83 | - 2023/12/14 - Added mention of Adam Johnson's article on nh3 + Django -------------------------------------------------------------------------------- /tests/examples/posts/2023-07-visit-to-paultons-park.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-07-11T23:45:00.00Z" 3 | published: true 4 | slug: 2023-07-visit-to-paultons-park 5 | tags: 6 | - uma 7 | - england 8 | - family 9 | - review 10 | time_to_read: 3 11 | title: Review of Paultons Park 12 | description: A quick review of Paultons Park Amusement World near Southhamption from the vantage point of a parent of a four year old child. 13 | type: post 14 | image: /images/uma-waterpark-summer-2023-40.jpg 15 | --- 16 | 17 | This weekend we took 4.5-year-old Uma to [Paultons Park amusement park](https://paultonspark.co.uk/) near Southhamption, UK. It was her first real amusement park experience, and she had a grand time. 18 | 19 | ## The many positives: 20 | 21 | - **The park is really well maintained**. Everything felt clean and proper, at the Disneyland level - if not better 22 | - **The rides for kids were fun and creative**, with supportive and friendly staff 23 | - **The restrooms were clean.** 24 | - **Peppa Pig World was really fun for our 4.5 year old**. She also enjoyed the Dinosaur area called "Lost Kingdom" 25 | - **The animal enclosures were nice**. Uma really liked the pink flamingo enclosure 26 | - **The water park was a good break from the rides**, fenced in so we knew Uma couldn't run off 27 | - **The playgrounds were fun and accurately marked for different age groups** 28 | - **This park is for kids, not teens**. There are a few roller coasters, but really this is for 12 years or under 29 | - **The mobile app is well executed**, we loved the estimated queue times and the map 30 | - **The food options were decent**, expensive but on the reasonable side for an amusement park. We brought our own food but splurged on ice cream and coffee 31 | - **Getting there by public transit from London was straight-forward**. We took the tube to the Waterloo Station, then a South West train to South Hamption Central. From there we jumped on the X7 bus right to the park entrance 32 | 33 | ## The single negative item: 34 | 35 | **Leaving was unnecessarily unpleasant** 36 | 37 | - The last bus from the park entrance to the train station left an hour before the park closed. To be fair, this is a public bus, not part of the park 38 | - There was another public bus stop a mile away from the park whose last bus left right at park closing 39 | - Discovering these details was really hard, the timetable was hidden inside the covered bus stop listed in a hard-to-follow format 40 | 41 | ## Summary: 42 | 43 | - Excellent park, would visit again. Next time we'll take into account that the last available bus leaves an hour before the park closes 44 | - The venue should provide a shuttle to-and-from the train station for people coming from London without cars 45 | 46 | ![Uma enjoying the splash area](/images/uma-waterpark-summer-2023-40.jpg) 47 | -------------------------------------------------------------------------------- /tests/examples/posts/2023-08-pypi-project-urls-cheatsheet.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-08-04T15:45:00.00Z" 3 | published: true 4 | slug: 2023-08-pypi-project-urls-cheatsheet 5 | tags: 6 | - howto 7 | - python 8 | - cheatsheet 9 | - packaging 10 | - TIL 11 | time_to_read: 2 12 | title: PyPI Project URLs Cheatsheet 13 | description: The PyPI project URLs spec is defined only in code. Here's my cheatsheet explaining how to configure them. I'll update this as I learn more (suggestions welcome!). Examples in several formats. 14 | type: post 15 | image: /logos/til-1.png 16 | twitter_image: /logos/til-1.png 17 | --- 18 | 19 | See these links in the image below? I want every PyPI project to have them in the left column. 20 | 21 | ![PyPI project URLs](/images/pypi-links-sidenav.png) 22 | 23 | The challenge is the PyPI project URLs spec is [defined only in code](https://github.com/pypi/warehouse/blob/70eac9796fa1eae24741525688a112586eab9010/warehouse/templates/packaging/detail.html#L20-L62). Here's my cheatsheet explaining how to configure them. I'll update this as I learn more (suggestions welcome!). Examples in several formats. 24 | 25 | # Example for pyproject.toml 26 | 27 | The `[project.urls]` table shown below only works if there is a `[project]` table. 28 | 29 | 30 | ```toml 31 | [project.urls] 32 | 33 | # Project homepage, often a link to GitHub or GitLab 34 | # Often specified in the [project] table 35 | homepage = "https://example.com" 36 | 37 | # The source code repository, don't use this if your homepage 38 | # is the repo 39 | repository = "https://github.com/me/spam.git" 40 | 41 | # The changelog, really useful for ongoing users of your project 42 | changelog = "https://github.com/me/spam/blob/master/CHANGELOG.md" 43 | 44 | # Any long form docs 45 | docs = "https://readthedocs.org" 46 | documentation = "https://readthedocs.org" 47 | 48 | # Bugs/issues/feature requests 49 | bugs = "https://change/requests" 50 | issues = "https://change/requests" 51 | tracker = "https://change/requests" 52 | 53 | # Really only useful if you have a binary download 54 | download = "https://pypi.org/project/spam/#files" 55 | 56 | # Funding requests, which hopefully you will get more than pizza money 57 | sponsor = "https://please/give/me/money" 58 | funding = "https://please/give/me/money" 59 | donate = "https://please/give/me/money" 60 | 61 | # Discussion areas 62 | mastodon = "https://mastodon/server/@me" 63 | twitter = "https://twitter/or/x" 64 | slack = "https://server/on/slack" 65 | reddit = "https://reddit/r/area" 66 | discord = "https://discord/area" 67 | gitter = "https://gitter/area" 68 | ``` 69 | 70 | # Example for poetry project 71 | 72 | Poetry has its own location for `urls` in the [tool.poetry.urls] table. Per the [Poetry documentation on urls](https://python-poetry.org/docs/pyproject/#urls): 73 | 74 | > "In addition to the basic urls (homepage, repository and documentation), you can specify any custom url in the urls section." 75 | 76 | ```toml 77 | [tool.poetry.urls] 78 | 79 | changelog = "https://github.com/mygithubusername/projectname/releases" 80 | documentation = "https://mygithubusername.github.io/projectname/" 81 | issues = "https://github.com/mygithubusername/projectname/issues" 82 | ``` 83 | 84 | # Example for setup.py 85 | 86 | For legacy reasons, here's the same thing in `setup.py` format. 87 | 88 | ```python 89 | # setup.py 90 | from setuptools import setup 91 | 92 | VERSION = "4.0.0" 93 | 94 | setup( 95 | name="xocto", 96 | version=VERSION, 97 | url="https://github.com/octoenergy/xocto" 98 | # ... 99 | project_urls={ 100 | "Documentation": "https://xocto.readthedocs.io", 101 | "Changelog": "https://github.com/octoenergy/xocto/blob/main/CHANGELOG.md", 102 | "Issues": "https://github.com/octoenergy/xocto/issues", 103 | }, 104 | ) 105 | ``` 106 | 107 | # See also 108 | 109 | As other resources from other people turn up, I'll add them here. 110 | 111 | - [Patrick Arminio's PyPI project URLs demo](https://github.com/patrick91/links-demo) - Added August 7, 2023 112 | -------------------------------------------------------------------------------- /tests/examples/posts/2023-10-we-moved-to-london.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-10-10T15:45:00.00Z" 3 | published: true 4 | slug: 2023-10-we-moved-to-london 5 | tags: 6 | - travel 7 | - family 8 | - uma 9 | - octopus 10 | - kraken 11 | time_to_read: 5 12 | title: We moved to London, UK! 13 | description: We're going to be in London, UK for a while, here's some of the details. 14 | type: post 15 | --- 16 | 17 | We're going to be here for a while in the United Kingdon, London to be precise. 18 | 19 | # Where are you living? 20 | 21 | In central London, not far from Oxford Circus. 22 | 23 | We had looked at living more on the periphery, but the cost of rent was the same or barely cheaper - not enough to cover the cost of commuting to the office. 24 | 25 | # What about the USA? 26 | 27 | The USA is our homeland and we're still US citizens. We still have family and friends there. We're planning on visiting the US at least once a year. 28 | 29 | # What about your daughter? 30 | 31 | Uma loves London and is going to school here. 32 | 33 | # If I'm in London can I meet up with you? 34 | 35 | Sure! Some options: 36 | 37 | - As I work in the building, I almost always go to [Django London](https://www.meetup.com/djangolondon/) 38 | - I go to at least one other London tech meetup per month 39 | - If you want to meet up for coffee, hit me up on social media 40 | 41 | # You work at [Kraken Tech](https://kraken.tech/), aren't they connected to Octopus Energy? 42 | 43 | I work at [Kraken Tech](https://kraken.tech/) building software for decarbonization- and electrification-focused retail energy companies like [Octopus Energy](https://octopus.energy). They are sister companies intent on improving the world. 44 | 45 | You can read in detail why I work for them [here](/posts/whats-the-best-thing-about-working-for-octopus-energy-part-1). Just replace everything saying "Octopus Energy" with "Kraken Tech" - the division in the USA was not there yet when I wrote it. 46 | 47 | # Do you have one of those Octopus Energy referral codes? 48 | 49 | While I work for Kraken Tech I'm a customer of Octopus Energy. So if you are anywhere on the planet served by Octopus Energy, you should be able to [use my referral code to get a £50 credit on your energy bill](https://share.octopus.energy/beige-dodo-940). -------------------------------------------------------------------------------- /tests/examples/posts/2023-11-minimal-css-libraries.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: "2023-11-20T17:30:00.00Z" 3 | published: true 4 | slug: til-2023-11-minimal-css-libraries 5 | tags: 6 | - blog 7 | time_to_read: 1 8 | title: "Minimal CSS libraries" 9 | description: Minimal CSS frameworks do not use classes, rather modifying the look of HTML components. That means that any CSS classes added are easier to discover and read. 10 | 11 | --- 12 | 13 | Minimal CSS frameworks do not use classes, rather modifying the look of HTML components. That means that any CSS classes added are easier to discover and read. 14 | 15 | I'm someone who struggles with CSS and the custom CSS of this site combined with complex non-obvious HTML (example being `