Compare commits
No commits in common. "main" and "migrate-rye-to-uv" have entirely different histories.
main
...
migrate-ry
44 changed files with 1047 additions and 766 deletions
14
.github/workflows/main.yml
vendored
Normal file
14
.github/workflows/main.yml
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
name: docker
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build the Docker image
|
||||
run: |
|
||||
echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io
|
||||
docker build . --file Dockerfile --tag docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:latest
|
||||
docker push docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:latest
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -15,5 +15,5 @@ blog.sublime-project
|
|||
blog.sublime-workspace
|
||||
ssl/
|
||||
.python-version
|
||||
.local
|
||||
uv.lock
|
||||
poetry.toml
|
||||
.local
|
28
Dockerfile
28
Dockerfile
|
@ -1,13 +1,31 @@
|
|||
FROM nginx:1.27.4-alpine-slim
|
||||
FROM nginx:1.27.3-alpine
|
||||
|
||||
RUN apk update && apk add --no-cache bash git python3 make tzdata curl py3-pip musl-locales
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && mv /root/.local/bin/uv /usr/local/bin
|
||||
RUN apk update
|
||||
RUN apk add --no-cache build-base bash git python3 make tzdata curl py3-pip libressl-dev musl-dev libffi-dev python3-dev cargo
|
||||
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# install locales
|
||||
ENV MUSL_LOCALE_DEPS cmake make musl-dev gcc gettext-dev libintl
|
||||
ENV MUSL_LOCPATH /usr/share/i18n/locales/musl
|
||||
|
||||
RUN apk add --no-cache \
|
||||
$MUSL_LOCALE_DEPS \
|
||||
&& wget https://gitlab.com/rilian-la-te/musl-locales/-/archive/master/musl-locales-master.zip \
|
||||
&& unzip musl-locales-master.zip \
|
||||
&& cd musl-locales-master \
|
||||
&& cmake -DLOCALE_PROFILE=OFF -D CMAKE_INSTALL_PREFIX:PATH=/usr . && make && make install \
|
||||
&& cd .. && rm -r musl-locales-master
|
||||
|
||||
# set timezone and locale
|
||||
RUN cp /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo "Europe/Paris" > /etc/timezone
|
||||
ENV TZ=Europe/Paris
|
||||
RUN cp /usr/share/zoneinfo/Europe/Paris /etc/localtime
|
||||
RUN echo "Europe/Paris" > /etc/timezone
|
||||
ENV TZ Europe/Paris
|
||||
ENV LANG fr_FR.UTF-8
|
||||
ENV LANGUAGE fr_FR.UTF-8
|
||||
ENV LC_ALL fr_FR.UTF-8
|
||||
|
||||
COPY docker/docker-init.sh /usr/local/bin/
|
||||
RUN chmod +x usr/local/bin/docker-init.sh
|
||||
|
|
68
Makefile
68
Makefile
|
@ -1,18 +1,64 @@
|
|||
# Makefile
|
||||
# Makefile
|
||||
#
|
||||
|
||||
# build target should be built even if no files depend on it
|
||||
.PHONY: build
|
||||
|
||||
# run locally
|
||||
site:
|
||||
# if a file .local exists run site locally
|
||||
ifeq ($(wildcard .local),)
|
||||
TARGET = site_remote
|
||||
else
|
||||
TARGET = site_local
|
||||
endif
|
||||
|
||||
site: $(TARGET)
|
||||
echo $(TARGET)
|
||||
|
||||
site_remote:
|
||||
git pull
|
||||
makesite
|
||||
systemctl reload nginx
|
||||
|
||||
site_local:
|
||||
uv run python makesite.py --params params-local.json
|
||||
cd _site && python -m SimpleHTTPServer 2> /dev/null || python3 -m http.server
|
||||
|
||||
# docker build image
|
||||
build:
|
||||
docker build -t source.madyanne.fr/yax/blog .
|
||||
|
||||
# docker publish image
|
||||
publish:
|
||||
docker push source.madyanne.fr/yax/blog
|
||||
dock: site_local
|
||||
$(shell docker start --interactive bloglocal || docker run --name bloglocal -p 80:80 -p 443:443 -v `pwd`/_site:/usr/share/nginx/html:ro -v `pwd`/nginx/nginx.conf:/etc/nginx/nginx.conf:ro -v `pwd`/nginx/dhparam.pem:/etc/nginx/dhparam.pem:ro -v `pwd`/ssl:/etc/nginx/ssl:ro nginx)
|
||||
|
||||
undock:
|
||||
docker stop bloglocal
|
||||
|
||||
build:
|
||||
docker build -t kianby/blog .
|
||||
|
||||
certs:
|
||||
mkdir -p ssl
|
||||
cd ssl ; wget -N https://traefik.me/cert.pem
|
||||
cd ssl ; wget -N https://traefik.me/chain.pem
|
||||
cd ssl ; wget -N https://traefik.me/fullchain.pem
|
||||
cd ssl ; wget -N https://traefik.me/privkey.pem
|
||||
|
||||
venv2:
|
||||
virtualenv ~/.venv/makesite
|
||||
echo . ~/.venv/makesite/bin/activate > venv
|
||||
. ./venv && pip install commonmark coverage
|
||||
|
||||
venv: FORCE
|
||||
python3 -m venv ~/.venv/makesite
|
||||
echo . ~/.venv/makesite/bin/activate > venv
|
||||
. ./venv && pip install commonmark coverage
|
||||
|
||||
test: FORCE
|
||||
. ./venv && python -m unittest -bv
|
||||
|
||||
coverage:
|
||||
. ./venv && coverage run --branch --source=. -m unittest discover -bv; :
|
||||
. ./venv && coverage report -m
|
||||
. ./venv && coverage html
|
||||
|
||||
clean:
|
||||
find . -name "__pycache__" -exec rm -r {} +
|
||||
find . -name "*.pyc" -exec rm {} +
|
||||
rm -rf .coverage htmlcov
|
||||
|
||||
FORCE:
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# Blog du Yax
|
||||
|
||||
This blog is built on top of the great work performed by fspaolo on [Makesite.py](https://github.com/fspaolo/makesite).
|
||||
I cut some features and wristed the code to focus on blog posts and support my [commenting system](https://gitea.zaclys.com/yannic/stacosys). You should check fspaolo's repository to really understand Makesite.py's philosophy and find technical details.
|
||||
I cut some features and wristed the code to focus on blog posts and support my [commenting system](https://github.com/kianby/stacosys). You should check fspaolo's repository to really understand Makesite.py's philosophy and find technical details.
|
||||
|
||||
This static blog generator code is under MIT license.
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
git fetch
|
||||
HEADHASH=$(git rev-parse HEAD)
|
||||
UPSTREAMHASH=$(git rev-parse main@{upstream})
|
||||
UPSTREAMHASH=$(git rev-parse master@{upstream})
|
||||
|
||||
if [ "$HEADHASH" != "$UPSTREAMHASH" ]
|
||||
then
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
#!/bin/bash
|
||||
|
||||
python -V
|
||||
#export POETRY_HOME=/opt/poetry
|
||||
|
||||
# clone and build blog
|
||||
cd /
|
||||
rm -rf /blog
|
||||
git clone https://gitea.zaclys.com/yannic/blog.git
|
||||
git clone https://github.com/kianby/blog.git
|
||||
cd /blog
|
||||
uv python pin 3.12.9
|
||||
uv sync
|
||||
uv run python ./makesite.py
|
||||
uv run make
|
||||
|
||||
# nginx serve
|
||||
#nginx -g 'daemon off;'
|
||||
|
|
|
@ -45,15 +45,6 @@ http {
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# redirections to preserve inbound links
|
||||
rewrite ^/migration-du-blog-sous-pelican.html$ https://blogduyax.madyanne.fr/2013/migration-du-blog-sous-pelican/ redirect;
|
||||
rewrite ^/2017/nextcloud-securite/$ https://blogduyax.madyanne.fr/2017/securite-des-donnees-focus-sur-nextcloud/ redirect;
|
||||
rewrite ^/installation-de-shinken.html$ https://blogduyax.madyanne.fr/2014/installation-de-shinken/ redirect;
|
||||
rewrite ^/retour-dexperience-ubuntu-touch.html$ https://blogduyax.madyanne.fr/2015/retour-dexperience-ubuntu-touch/ redirect;
|
||||
rewrite ^/haute-disponibilite-avec-corosync-et-pacemaker.html$ https://blogduyax.madyanne.fr/2013/haute-disponibilite-avec-corosync-et-pacemaker/ redirect;
|
||||
rewrite ^/feeds/all.atom.xml$ https://blogduyax.madyanne.fr/rss.xml redirect;
|
||||
rewrite ^/feed.xml$ https://blogduyax.madyanne.fr/rss.xml redirect;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
<link>{{ site_url }}/{{ post_url }}/</link>
|
||||
<description>
|
||||
<![CDATA[
|
||||
{{ content_rss }}
|
||||
<p>
|
||||
{{ content }}
|
||||
</p>
|
||||
]]>
|
||||
</description>
|
||||
<pubDate>{{ rfc_2822_date }}</pubDate>
|
||||
|
|
282
makesite.py
282
makesite.py
|
@ -1,5 +1,30 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# The MIT License (MIT)
|
||||
#
|
||||
# Copyright (c) 2018 Sunaina Pai
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
# Modifed by Yax
|
||||
|
||||
"""Make static website/blog with Python."""
|
||||
|
||||
import argparse
|
||||
|
@ -21,11 +46,11 @@ from pygments import highlight
|
|||
from pygments.lexers import get_lexer_by_name
|
||||
from pygments.formatters import html
|
||||
|
||||
# set user locale
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
|
||||
FRENCH_WEEKDAYS = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.']
|
||||
FRENCH_MONTHS = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin',
|
||||
'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.']
|
||||
|
||||
# initialize markdown
|
||||
|
||||
class HighlightRenderer(mistune.HTMLRenderer):
|
||||
def block_code(self, code, info=None):
|
||||
|
@ -35,7 +60,6 @@ class HighlightRenderer(mistune.HTMLRenderer):
|
|||
return highlight(code, lexer, formatter)
|
||||
return '<pre><code>' + mistune.escape(code) + '</code></pre>'
|
||||
|
||||
|
||||
markdown = mistune.create_markdown(renderer=HighlightRenderer())
|
||||
|
||||
|
||||
|
@ -62,7 +86,7 @@ def log(msg, *args):
|
|||
|
||||
def truncate(text, words=25):
|
||||
"""Remove tags and truncate text to the specified number of words."""
|
||||
return " ".join(re.sub(r"(?s)<.*?>", " ", text).split()[:words])
|
||||
return " ".join(re.sub("(?s)<.*?>", " ", text).split()[:words])
|
||||
|
||||
|
||||
def read_headers(text):
|
||||
|
@ -76,9 +100,9 @@ def read_headers(text):
|
|||
def rfc_2822_format(date_str):
|
||||
"""Convert yyyy-mm-dd date string to RFC 2822 format date string."""
|
||||
d = datetime.datetime.strptime(date_str, "%Y-%m-%d")
|
||||
return d \
|
||||
.replace(tzinfo=datetime.timezone.utc) \
|
||||
.strftime('%a, %d %b %Y %H:%M:%S %z')
|
||||
dtuple = d.timetuple()
|
||||
dtimestamp = time.mktime(dtuple)
|
||||
return utils.formatdate(dtimestamp)
|
||||
|
||||
|
||||
def slugify(value):
|
||||
|
@ -88,15 +112,13 @@ def slugify(value):
|
|||
trailing whitespace.
|
||||
"""
|
||||
value = (
|
||||
unicodedata.normalize("NFKD", value)
|
||||
.encode("ascii", "ignore")
|
||||
.decode("ascii")
|
||||
unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
|
||||
)
|
||||
value = re.sub(r"[^\w\s-]", "", value).strip().lower()
|
||||
return re.sub(r"[-\s]+", "-", value)
|
||||
value = re.sub("[^\w\s-]", "", value).strip().lower()
|
||||
return re.sub("[-\s]+", "-", value)
|
||||
|
||||
|
||||
def read_content(filename, params):
|
||||
def read_content(filename):
|
||||
"""Read content and metadata from file into a dictionary."""
|
||||
# Read file content.
|
||||
text = fread(filename)
|
||||
|
@ -133,7 +155,6 @@ def read_content(filename, params):
|
|||
content.update(
|
||||
{
|
||||
"content": text,
|
||||
"content_rss": fix_relative_links(params["site_url"], text),
|
||||
"rfc_2822_date": rfc_2822_format(content["date"]),
|
||||
"summary": summary,
|
||||
}
|
||||
|
@ -142,20 +163,12 @@ def read_content(filename, params):
|
|||
return content
|
||||
|
||||
|
||||
def fix_relative_links(site_url, text):
|
||||
"""Absolute links needed in RSS feed"""
|
||||
# TODO externalize links replacement configuration
|
||||
return text \
|
||||
.replace("src=\"/images/20", "src=\"" + site_url + "/images/20") \
|
||||
.replace("href=\"/20", "href=\"" + site_url + "/20")
|
||||
|
||||
|
||||
def clean_html_tag(text):
|
||||
"""Remove HTML tags."""
|
||||
while True:
|
||||
original_text = text
|
||||
text = re.sub(r"<\w+.*?>", "", text)
|
||||
text = re.sub(r"<\/\w+>", "", text)
|
||||
text = re.sub("<\w+.*?>", "", text)
|
||||
text = re.sub("<\/\w+>", "", text)
|
||||
if original_text == text:
|
||||
break
|
||||
return text
|
||||
|
@ -171,30 +184,28 @@ def render(template, **params):
|
|||
|
||||
|
||||
def get_header_list_value(header_name, page_params):
|
||||
header_list = []
|
||||
l = []
|
||||
if header_name in page_params:
|
||||
for s in page_params[header_name].split(" "):
|
||||
if s.strip():
|
||||
header_list.append(s.strip())
|
||||
return header_list
|
||||
l.append(s.strip())
|
||||
return l
|
||||
|
||||
|
||||
def get_friendly_date(date_str):
|
||||
dt = datetime.datetime.strptime(date_str, "%Y-%m-%d")
|
||||
french_month = FRENCH_MONTHS[dt.month - 1]
|
||||
return f"{dt.day:02d} {french_month} {dt.year}"
|
||||
return dt.strftime("%d %b %Y")
|
||||
|
||||
|
||||
def make_posts(
|
||||
src, src_pattern, dst, layout, category_layout,
|
||||
comment_layout, comment_detail_layout, **params
|
||||
src, src_pattern, dst, layout, category_layout, comment_layout, comment_detail_layout, **params
|
||||
):
|
||||
"""Generate posts from posts directory."""
|
||||
items = []
|
||||
|
||||
for posix_path in Path(src).glob(src_pattern):
|
||||
src_path = str(posix_path)
|
||||
content = read_content(src_path, params)
|
||||
content = read_content(src_path)
|
||||
|
||||
# render text / summary for basic fields
|
||||
content["content"] = render(content["content"], **params)
|
||||
|
@ -206,18 +217,13 @@ def make_posts(
|
|||
page_params["date_path"] = page_params["date"].replace("-", "/")
|
||||
page_params["friendly_date"] = get_friendly_date(page_params["date"])
|
||||
page_params["year"] = page_params["date"].split("-")[0]
|
||||
page_params["post_url"] = \
|
||||
page_params["year"] \
|
||||
+ "/" \
|
||||
+ page_params["slug"] + "/"
|
||||
page_params["post_url"] = page_params["year"] + "/" + page_params["slug"] + "/"
|
||||
|
||||
# categories
|
||||
categories = get_header_list_value("category", page_params)
|
||||
out_cats = []
|
||||
for category in categories:
|
||||
out_cat = render(category_layout,
|
||||
category=category,
|
||||
url=slugify(category))
|
||||
out_cat = render(category_layout, category=category, url=slugify(category))
|
||||
out_cats.append(out_cat.strip())
|
||||
page_params["categories"] = categories
|
||||
page_params["category_label"] = "".join(out_cats)
|
||||
|
@ -293,7 +299,7 @@ def make_notes(
|
|||
|
||||
for posix_path in Path(src).glob(src_pattern):
|
||||
src_path = str(posix_path)
|
||||
content = read_content(src_path, params)
|
||||
content = read_content(src_path)
|
||||
|
||||
# render text / summary for basic fields
|
||||
content["content"] = render(content["content"], **params)
|
||||
|
@ -303,12 +309,12 @@ def make_notes(
|
|||
page_params["header"] = ""
|
||||
page_params["footer"] = ""
|
||||
page_params["friendly_date"] = ""
|
||||
page_params["category_label"] = ""
|
||||
page_params["category_label"] = ""
|
||||
page_params["post_url"] = "notes/" + page_params["slug"] + "/"
|
||||
|
||||
content["post_url"] = page_params["post_url"]
|
||||
content["friendly_date"] = page_params["friendly_date"]
|
||||
content["category_label"] = page_params["category_label"]
|
||||
content["category_label"] = page_params["category_label"]
|
||||
items.append(content)
|
||||
|
||||
dst_path = render(dst, **page_params)
|
||||
|
@ -321,8 +327,7 @@ def make_notes(
|
|||
|
||||
|
||||
def make_list(
|
||||
posts, dst, list_layout, item_layout,
|
||||
header_layout, footer_layout, **params
|
||||
posts, dst, list_layout, item_layout, header_layout, footer_layout, **params
|
||||
):
|
||||
"""Generate list page for a blog."""
|
||||
|
||||
|
@ -363,17 +368,53 @@ def make_list(
|
|||
fwrite(dst_path, output)
|
||||
|
||||
|
||||
def create_blog(page_layout, list_in_page_layout, params):
|
||||
def main(param_file):
|
||||
# Create a new _site directory from scratch.
|
||||
if os.path.isdir("_site"):
|
||||
shutil.rmtree("_site")
|
||||
shutil.copytree("static", "_site")
|
||||
|
||||
# Default parameters.
|
||||
params = {
|
||||
"title": "Blog",
|
||||
"subtitle": "Lorem Ipsum",
|
||||
"author": "Admin",
|
||||
"site_url": "http://localhost:8000",
|
||||
"current_year": datetime.datetime.now().year,
|
||||
"stacosys_url": "",
|
||||
}
|
||||
|
||||
log("use params from " + param_file)
|
||||
if os.path.isfile(param_file):
|
||||
params.update(json.loads(fread(param_file)))
|
||||
|
||||
# Load layouts.
|
||||
banner_layout = fread("layout/banner.html")
|
||||
paging_layout = fread("layout/paging.html")
|
||||
archive_title_layout = fread("layout/archives.html")
|
||||
page_layout = fread("layout/page.html")
|
||||
post_layout = fread("layout/post.html")
|
||||
post_layout = render(page_layout, content=post_layout)
|
||||
list_layout = fread("layout/list.html")
|
||||
item_layout = fread("layout/item.html")
|
||||
item_nosummary_layout = fread("layout/item_nosummary.html")
|
||||
item_note_layout = fread("layout/item_note.html")
|
||||
category_title_layout = fread("layout/category_title.html")
|
||||
category_layout = fread("layout/category.html")
|
||||
comment_layout = fread("layout/comment.html")
|
||||
comment_detail_layout = fread("layout/comment-detail.html")
|
||||
category_layout = fread("layout/category.html")
|
||||
item_layout = fread("layout/item.html")
|
||||
rss_xml = fread("layout/rss.xml")
|
||||
rss_item_xml = fread("layout/rss_item.xml")
|
||||
sitemap_xml = fread("layout/sitemap.xml")
|
||||
sitemap_item_xml = fread("layout/sitemap_item.xml")
|
||||
note_layout = fread("layout/note.html")
|
||||
|
||||
posts = make_posts(
|
||||
# Combine layouts to form final layouts.
|
||||
post_layout = render(page_layout, content=post_layout)
|
||||
list_layout = render(page_layout, content=list_layout)
|
||||
note_layout = render(page_layout, content=note_layout)
|
||||
|
||||
# Create blogs.
|
||||
blog_posts = make_posts(
|
||||
"posts",
|
||||
"**/*.md",
|
||||
"_site/{{ post_url }}/index.html",
|
||||
|
@ -384,11 +425,10 @@ def create_blog(page_layout, list_in_page_layout, params):
|
|||
**params
|
||||
)
|
||||
|
||||
# Create blog list pages by 10.
|
||||
# Create blog list pages.
|
||||
page_size = 10
|
||||
chunk_posts = [
|
||||
posts[i: i + page_size]
|
||||
for i in range(0, len(posts), page_size)
|
||||
blog_posts[i: i + page_size] for i in range(0, len(blog_posts), page_size)
|
||||
]
|
||||
page = 1
|
||||
last_page = len(chunk_posts)
|
||||
|
@ -403,7 +443,7 @@ def create_blog(page_layout, list_in_page_layout, params):
|
|||
make_list(
|
||||
chunk,
|
||||
"_site/index.html",
|
||||
list_in_page_layout,
|
||||
list_layout,
|
||||
item_layout,
|
||||
banner_layout,
|
||||
paging_layout,
|
||||
|
@ -414,21 +454,17 @@ def create_blog(page_layout, list_in_page_layout, params):
|
|||
make_list(
|
||||
chunk,
|
||||
"_site/page" + str(page) + "/index.html",
|
||||
list_in_page_layout,
|
||||
list_layout,
|
||||
item_layout,
|
||||
banner_layout,
|
||||
paging_layout,
|
||||
**params
|
||||
)
|
||||
page = page + 1
|
||||
return posts
|
||||
|
||||
|
||||
def generate_categories(list_in_page_layout, item_nosummary_layout,
|
||||
posts, params):
|
||||
category_title_layout = fread("layout/category_title.html")
|
||||
# Create category pages
|
||||
cat_post = {}
|
||||
for post in posts:
|
||||
for post in blog_posts:
|
||||
for cat in post["categories"]:
|
||||
if cat in cat_post:
|
||||
cat_post[cat].append(post)
|
||||
|
@ -439,60 +475,29 @@ def generate_categories(list_in_page_layout, item_nosummary_layout,
|
|||
make_list(
|
||||
cat_post[cat],
|
||||
"_site/" + slugify(cat) + "/index.html",
|
||||
list_in_page_layout,
|
||||
list_layout,
|
||||
item_nosummary_layout,
|
||||
category_title_layout,
|
||||
None,
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
def generate_archives(blog_posts, list_in_page_layout, item_nosummary_layout,
|
||||
archive_title_layout, params):
|
||||
# Create archive page
|
||||
make_list(
|
||||
blog_posts,
|
||||
"_site/archives/index.html",
|
||||
list_in_page_layout,
|
||||
list_layout,
|
||||
item_nosummary_layout,
|
||||
archive_title_layout,
|
||||
None,
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
def generate_notes(page_layout, archive_title_layout,
|
||||
list_in_page_layout, params):
|
||||
note_layout = fread("layout/note.html")
|
||||
item_note_layout = fread("layout/item_note.html")
|
||||
note_layout = render(page_layout, content=note_layout)
|
||||
|
||||
notes = make_notes(
|
||||
"notes",
|
||||
"**/*.md",
|
||||
"_site/{{ post_url }}/index.html",
|
||||
note_layout,
|
||||
**params
|
||||
)
|
||||
|
||||
make_list(
|
||||
notes,
|
||||
"_site/notes/index.html",
|
||||
list_in_page_layout,
|
||||
item_note_layout,
|
||||
archive_title_layout,
|
||||
None,
|
||||
**params
|
||||
)
|
||||
|
||||
|
||||
def generate_rss_feeds(posts, params):
|
||||
rss_xml = fread("layout/rss.xml")
|
||||
rss_item_xml = fread("layout/rss_item.xml")
|
||||
|
||||
# Create main RSS feed for 10 last entries
|
||||
# Create main RSS feed for 10 last entries
|
||||
nb_items = min(10, len(blog_posts))
|
||||
for filename in ("_site/rss.xml", "_site/index.xml"):
|
||||
make_list(
|
||||
posts[:10],
|
||||
blog_posts[:nb_items],
|
||||
filename,
|
||||
rss_xml,
|
||||
rss_item_xml,
|
||||
|
@ -503,7 +508,7 @@ def generate_rss_feeds(posts, params):
|
|||
|
||||
# Create RSS feed by tag
|
||||
tag_post = {}
|
||||
for post in posts:
|
||||
for post in blog_posts:
|
||||
for tag in post["tags"]:
|
||||
if tag in tag_post:
|
||||
tag_post[tag].append(post)
|
||||
|
@ -521,12 +526,9 @@ def generate_rss_feeds(posts, params):
|
|||
**params
|
||||
)
|
||||
|
||||
|
||||
def generate_sitemap(posts, params):
|
||||
sitemap_xml = fread("layout/sitemap.xml")
|
||||
sitemap_item_xml = fread("layout/sitemap_item.xml")
|
||||
# Create sitemap
|
||||
make_list(
|
||||
posts,
|
||||
blog_posts,
|
||||
"_site/sitemap.xml",
|
||||
sitemap_xml,
|
||||
sitemap_item_xml,
|
||||
|
@ -536,62 +538,30 @@ def generate_sitemap(posts, params):
|
|||
)
|
||||
|
||||
|
||||
def get_params(param_file):
|
||||
# Default parameters.
|
||||
params = {
|
||||
"title": "Blog",
|
||||
"subtitle": "Lorem Ipsum",
|
||||
"author": "Admin",
|
||||
"site_url": "http://localhost:8000",
|
||||
"current_year": datetime.datetime.now().year,
|
||||
"stacosys_url": "",
|
||||
}
|
||||
# Create notes.
|
||||
notes = make_notes(
|
||||
"notes",
|
||||
"**/*.md",
|
||||
"_site/{{ post_url }}/index.html",
|
||||
note_layout,
|
||||
**params
|
||||
)
|
||||
|
||||
log("use params from " + param_file)
|
||||
if os.path.isfile(param_file):
|
||||
params.update(json.loads(fread(param_file)))
|
||||
return params
|
||||
|
||||
|
||||
def clean_site():
|
||||
if os.path.isdir("_site"):
|
||||
shutil.rmtree("_site")
|
||||
shutil.copytree("static", "_site")
|
||||
|
||||
|
||||
def main(param_file):
|
||||
|
||||
params = get_params(param_file)
|
||||
|
||||
# Create a new _site directory from scratch.
|
||||
clean_site()
|
||||
|
||||
# Load layouts.
|
||||
page_layout = fread("layout/page.html")
|
||||
list_layout = fread("layout/list.html")
|
||||
list_in_page_layout = render(page_layout, content=list_layout)
|
||||
archive_title_layout = fread("layout/archives.html")
|
||||
item_nosummary_layout = fread("layout/item_nosummary.html")
|
||||
|
||||
blog_posts = create_blog(page_layout, list_in_page_layout, params)
|
||||
|
||||
generate_categories(list_in_page_layout, item_nosummary_layout,
|
||||
blog_posts, params)
|
||||
|
||||
generate_archives(blog_posts, list_in_page_layout, item_nosummary_layout,
|
||||
archive_title_layout, params)
|
||||
|
||||
generate_notes(page_layout, archive_title_layout,
|
||||
list_in_page_layout, params)
|
||||
|
||||
generate_rss_feeds(blog_posts, params)
|
||||
|
||||
generate_sitemap(blog_posts, params)
|
||||
make_list(
|
||||
notes,
|
||||
"_site/notes/index.html",
|
||||
list_layout,
|
||||
item_note_layout,
|
||||
archive_title_layout,
|
||||
None,
|
||||
**params
|
||||
)
|
||||
|
||||
# Test parameter to be set temporarily by unit tests.
|
||||
_test = None
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description='Makesite')
|
||||
parser.add_argument('--params', dest='param_file', type=str,
|
||||
default="params.json", help='Custom param file')
|
||||
parser.add_argument('--params', dest='param_file', type=str, default="params.json", help='Custom param file')
|
||||
args = parser.parse_args()
|
||||
main(args.param_file)
|
||||
|
|
44
makesite.spec
Normal file
44
makesite.spec
Normal file
|
@ -0,0 +1,44 @@
|
|||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
|
||||
block_cipher = None
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['makesite.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='makesite',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
|
@ -12,10 +12,6 @@ Supprimer les dépendances des paquets orphelins
|
|||
|
||||
pacman -Rsn $(pacman -Qtdq)
|
||||
|
||||
Nettoyage du cache pacman des archives de +90 jours
|
||||
|
||||
find ./ -maxdepth 1 -type f -mtime +90 -print0 | xargs -0 sudo /bin/rm -f
|
||||
|
||||
## *Downgrader* des paquets
|
||||
|
||||
Récupérer la liste des upgraded ([source](https://wiki.archlinux.org/title/Downgrading_packages))
|
||||
|
@ -40,7 +36,6 @@ for i in $(cat /tmp/packages); do sudo pacman --noconfirm -U "$i"*.zst; done
|
|||
|
||||
(potentiel problème d'ordre, réarranger le fichier /tmp/packages en fonction des dépendances entre les paquets)
|
||||
|
||||
|
||||
# Fedora
|
||||
|
||||
Historique des transactions **dnf**
|
||||
|
|
|
@ -2,8 +2,7 @@
|
|||
"title": "Le blog du Yax",
|
||||
"subtitle": "GNU, Linux, BSD et autres libertés",
|
||||
"author": "Yax",
|
||||
"site_url": "http://127.0.0.1:8000",
|
||||
"site_url": "http://127.0.0.1:8000",
|
||||
"stacosys_url": "http://127.0.0.1:8100/api",
|
||||
"stacosys_url": "",
|
||||
"external_check": "./check_git.sh"
|
||||
}
|
||||
|
|
|
@ -4,7 +4,8 @@
|
|||
|
||||
Le titre est un peu provoc mais c'est une vraie question [que je me posais
|
||||
déjà en Novembre
|
||||
dernier](/2011/gnome-3-pour-un-usage-professionnel). Comment retrouver un niveau de productivité correct après
|
||||
dernier](http://blogduyax.madyanne.fr/index.php?article60/gnome-3-pour-un-usage-
|
||||
professionnel). Comment retrouver un niveau de productivité correct après
|
||||
l'ouragan Gnome 3 / Unity dans le cadre professionnel<!-- more --> avec du matos récent (en
|
||||
l'occurrence un core i7 avec de la RAM à gogo et une carte NVIDIA Optimus
|
||||
achetés en décembre) ? J'ai posé deux contraintes : **une stabilité des
|
||||
|
@ -71,11 +72,7 @@ Intel est bien suffisante pour mon usage.
|
|||
Tous les outils que j'utilise quotidiennement pour le travail fonctionnent
|
||||
parfaitement (Eclipse, JAVA, VMWare Player, Skype), l'environnement de bureau
|
||||
est un bonheur retrouvé. Pour info, Ubuntu 10.04 démarre et s'arrête 2 fois
|
||||
plus vite qu'une version 11.10.
|
||||
|
||||
> Que fait Canonical depuis 3 versions ?
|
||||
>
|
||||
> ah oui ils focalisent sur l'environnement de bureau :-(
|
||||
|
||||
Après un mois d'errements, j'ai enfin l'impression d'avoir la distribution qu'il me faut pour cette
|
||||
plus vite qu'une version 11.10. Que fait Canonical depuis 3 versions ? - ah oui
|
||||
ils focalisent sur l'environnement de bureau :-( Après un mois d'errements,
|
||||
j'ai enfin l'impression d'avoir la distribution qu'il me faut pour cette
|
||||
machine. J'aurais pu intituler cet article "Retour vers le futur".
|
||||
|
|
|
@ -4,7 +4,10 @@
|
|||
|
||||
Ma dernière tentative d'utiliser Gnome en environnement professionnel date de
|
||||
[fin 2011](/2011/gnome-3-pour-un-usage-professionnel). Le changement avait été
|
||||
trop brutal et déclenché [mon passage à XFCE](/2012/quelle-distribution-gnome-2-choisir-en-2012/)<!-- more --> où je suis resté depuis, que ce soit sous Arch à la maison ou au bureau sous Fedora. En 2 ans, les développeurs de Gnome ont sacrément amélioré leur bijou :
|
||||
trop brutal et déclenché [mon passage à XFCE](quelle-distribution-gnome-2
|
||||
-choisir-en-2012.html)<!-- more --> où je suis resté depuis, que ce soit sous Arch à la
|
||||
maison ou au bureau sous Fedora. En 2 ans, les développeurs de Gnome ont
|
||||
sacrément amélioré leur bijou :
|
||||
|
||||
- meilleures performances, cohésion de l'interface, raffinements
|
||||
- ergonomiques et esthétiques à tous les étages, ouverture grâce aux
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
<!-- title: Retour en auto-hébergement -->
|
||||
<!-- category: Hébergement -->
|
||||
|
||||
Après une dizaine d'années à héberger mes services chez différents hébergeurs je reviens à de l'auto-hébergement sur du matériel à la maison. Et quelle évolution au niveau matériel : en 2013 c'était un portable Céléron peut véloce dans un placard. En cette fin d'année j'ai acquis un mini-pc sur les conseils avisés de [MiniMachines](https://www.minimachines.net) : un Beelink Mini S12 Pro: pour la taille d'un Rubik's Cube on a un processeur Intel N100 (de la famille Alder Lake) appuyé par 16 Go de RAM et 512 Go de stockage en M.2. Ça coûte un peu plus de 200 euros et c'est très raisonnable par rapport à ce qu'on obtient en retour : une machine avec une consommation électrique sobre, quasi-silencieuse et des performances suffisantes pour des usages standards en auto-hébergement.
|
||||
|
||||
Pour moi l'objectif est double :
|
||||
- financier d'abord avec l'opportunité de réduire mes factures bien que je sois très satisfait de mes services externes : résilier le cloud Infomaniak et le petit VPS me fera économiser d'une bonne centaine d'euros par an.
|
||||
- apprendre en s'amusant : refaire de l'administration système, découvrir des nouveaux services à héberger, ça ouvre plein de possibilités.
|
||||
|
||||
Joli projet sur le papier mais moins simple qu'en 2012 car l'Internet est sacrément plus hostile et une grosse réflexion sur la sécurité s'est imposée avant d’ouvrir quoi que ce soit sur Internet. Finalement la solution sera basée sur l'hyperviseur Proxmox qui apporte une souplesse sur les types de déploiement en permettant de mixer des conteneurs LXC et des machines virtuelles KVM et d'apporter une brique de sécurité avec un pare-feu à multiple niveaux. Le but étant d'isoler autant que possible les parties exposées du réseau domestique. Les machines virtuelles [regrouperont des services Docker](https://github.com/kianby/selfhosting/tree/config-vm1) exposés indirectement par un proxy NginX.
|
||||
|
||||

|
||||
|
||||
Le proxy NginX est directement exposé sur Internet via une redirection des ports HTTP / HTTPs depuis la box internet. C'est un container LXC Alpine avec une installation de [Nginx Proxy Manager](https://nginxproxymanager.com/) modifiée pour que les services ne s'exécutent par avec le super-utilisateur *root*. Le minimum de paquets est installé (surtout pas de service SSHD ni de SSH) et il s'administre par l'interface Web de Nginx Proxy Manager depuis le réseau local qui n'est évidemment pas exposée à l'extérieur. Il a deux cartes réseau virtuelles : une adresse sur le réseau local et l'autre sur le réseau privé constitué des machines virtuelles exécutant les services. Le proxy sert aussi de passerelle de sortie aux machines virtuelles : le routage est activé entre les deux interfaces et l'interface du réseau privé est *bridgée* sur l'interface réseau locale.
|
||||
|
||||
Les machines virtuelles exécutant les services appartiennent au réseau privé et le pare-feu de l'hyperviseur bloque le trafic pour qu'elles ne puisse communiquer qu'avec la passerelle. En cas de compromission elles n'ont accès ni au réseau local ni au bastion. Chaque machine virtuelle est accessible par SSH depuis le bastion, qui est un simple conteneur LXC Alpine avec deux cartes réseau qui permet par rebond d'accéder aux machines virtuelles depuis un PC du réseau local. Excepté la console Web de Proxmox c'est le seul moyen d'accéder aux machines virtuelles. Les accès SSH sont protégés par échange de clefs (aucun accès autorisé par mot de passe) et seul le bastion est autorisé. Corollaire : n'importe quelle machine du réseau local ayant un accès SSH au bastion peut accéder aux machines virtuelles.
|
||||
|
||||

|
||||
|
||||
Voilà c'est sûrement perfectible mais j'ai jugé la solution suffisamment sécurisée pour la mettre en service. La machine "vm2" avec Nextcloud et Immlich est encore un projet mais la machine "vm1" exécutant tous mes services de base est déjà opérationnelle.
|
||||
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<!-- title: Analyse de logs -->
|
||||
<!-- category: Hébergement -->
|
||||
|
||||
Après quelques semaines [sur ma nouvelle installation de serveur](/2024/retour-en-auto-hebergement/) j'ai eu besoin de visibilité sur les visites pour estimer si la sécurité devait être renforcée. De longue date j'avais mis en favori l'outil [GoAccess](https://goaccess.io) et il semblait correspondre à ce que je cherchais, à savoir un outil passif de génération de rapports basé sur les logs d'accès du serveur HTTP : pas d'analyse des IP source mais des statistiques d'accès par site, une répartition des *User Agent* utilisés avec une reconnaissance des bots (ou crawlers) qui représentent le plus gros du trafic, les erreurs HTTP, les URI les plus recherchées ...
|
||||
|
||||
A ma grande satisfaction j'ai mis en place GoAccess en moins de deux heures avec deux étapes :
|
||||
1. l'installation sur le container LXC Alpine. GoAccess est écrit en langage C et a peu de dépendances pour la compilation. Avec deux ou trois dépendances de librairies ajoutées, la compilation sur le container a été simple.
|
||||
2. le travail principal consiste à décrire le format du log à analyser dans le formalisme de GoAccess
|
||||
|
||||
Nginx Proxy Manager utilise un format de log commun pour tous les proxy ce qui m'a simplifié la tâche. La documentation de GoAccess est exemplaire, notamment le formalisme des logs (https://goaccess.io/man#custom-log).
|
||||
|
||||
Pour ce format de log Nginx :
|
||||
|
||||
```
|
||||
log_format proxy '[$time_local] $upstream_cache_status $upstream_status $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] [Sent-to $server] "$http_user_agent" "$http_referer"';
|
||||
```
|
||||
|
||||
j'ai défini ces paramètres de lancement :
|
||||
|
||||
```
|
||||
--datetime-format='%d/%b/%Y:%H:%M:%S %z'
|
||||
--log-format='[%x] - %^ %s - %m %^ %v "%U" [Client %h] [Length %b] [Gzip %^] [Sent-to %^] "%u" "%R"'
|
||||
```
|
||||
|
||||
GoAccess peut générer son rapport HTML à partir d'une liste de fichiers de logs à la demande ou en temps-réel avec un lancement en tâche de fond (option --real-time-html --daemonize). L'option temps-réel est sympa avec une web socket qui rafraîchit la page du navigateur automatiquement mais c'est consommateur en CPU sur ce type d'installation en mini-pc où l'un des objectifs est la sobriété énergétique. J'ai préféré opter pour une génération horaire par une tâche planifiée avec CRON.
|
||||
|
||||
GoAccess répond à mon besoin et j'ai commencé à analyser les données pour préparer une phase de renforcement de la sécurité.
|
|
@ -1,8 +0,0 @@
|
|||
<!-- title: Est-ce que tout est politique ? -->
|
||||
<!-- category: Humeur -->
|
||||
|
||||
Le retour en auto-hébergement avec [l'achat d'un mini-pc](/2024/retour-en-auto-hebergement/) a été ma meilleure idée de fin d'année. Et ce n'est pas suffisant car j'ai encore beaucoup de dépendance à des services U.S. Même si je voyais d'un œil inquiet la réélection de Donald (pas le canard rigolo qui a bercé mon enfance... l'autre), jamais je n'aurais imaginé le système fasciste qui se met en place depuis le début d'année. Naïf, je pensais qu'il y avait des garde-fous, qu'on ne pouvait pas poignarder un système démocratique sans qu'une instance siffle un arrêt de jeu, mais je me trompais lourdement. Je ne rentrerais pas dans le détail des mesures appliquées par ce nouveau gouvernement sinon cet article va devenir trop triste ; j'ai juste le cœur qui saigne pour tous ceux qui en souffrent, sûrement une vilaine faiblesse de mon côté woke ;-)
|
||||
|
||||
Depuis tant d'années nous baignons dans ce monde du partage communautaire et multiculturel autour de Linux, des projets libres et opensource. Alors est-ce qu'on fait de la politique ? Pas forcément consciemment mais nous essayons de vivre en accord avec nos valeur et nos goûts reflètent une vision du monde (pas tel qu'il est malheureusement mais plutôt comme nous le rêvons). Le durcissement des gouvernements au détriment de l'humain (en piétinant d'abord les minorités) et de la nature, le repli en mode écureuil (tiens si je prends le Groenland je pourrais assurer le même train de vie pendant encore quelques dizaines d'année et faire croire que tout va bien ?) nous expose peu à peu. Si on n'est pas d'accord avec cela car nous avons d'autres valeurs plus humanistes alors je crains que nous ayons une posture politique à l'insu de nous-mêmes même :-) Le fait qu'on ait jamais voté ou qu'on soit fidèle aux urnes ne change rien à l'affaire : il y a une majorité silencieuse qui n'approuve pas, ne défile pas, du moins j'aime à le croire. Quelle est la limite qui fait basculer de l'indignation à l'action, je me pose la question... à moi-même en premier lieu.
|
||||
|
||||
L'année va être intéressante mais pas forcément joyeuse. Prenez soin de vous, mentalement aussi, car la période est malsaine.
|
|
@ -1,20 +0,0 @@
|
|||
<!-- title: Migration Git et optimisation Docker -->
|
||||
<!-- category: Hébergement -->
|
||||
|
||||
|
||||
|
||||
J'ai quitté GitHub (entre autres) ! Enfin presque car je n'ai pas encore recensé les sites où mon compte GitHub sert d'authentification mais j'ai fait le plus dur : supprimer tous mes projets et ne migrer que ceux encore actifs ; un peu dur car supprimer des projets, mêmes archivés, pour un développeur c'est un crève-cœur mais le spleen est vite passé avec l'engouement de repartir à zéro sur une plate-forme plus conforme à mes valeurs, la forge de [Zaclys](https://www.zaclys.com) et je les remercie grandement d'avoir ajouté ce service à leur offre "Famille".
|
||||
|
||||
Après migration, il me reste quatre projets actifs (hé oui il ne reste que ça) à savoir mes configurations (dotfiles), le déploiement par docker compose (selfhosting), le gestionnaire de commentaires (stacosys) et le blog. Vous avez noté la subtilité pour être retrouvé sur la [forge de Zaclys](https://gitea.zaclys.com) sans donner explicitement le lien de mon espace dans l'article ;-)
|
||||
|
||||
Sur mon infrastructure domestique (le mini-pc Beelink propulsé par l'hyperviseur Proxmox) j'ai déjà une instance Gitea qui réplique mes projets à l'extérieur, ça me garantit d'avoir une copie à jour en cas de gros pépin. Bref après avoir migré mes projets je me suis trouvé devant la problématique de construire et publier les images Docker de stacosys et du blog puisque cette tâche était dévolue aux actions GitHub. Je ne sais pas si la forge Zaclys propose un équivalent mais je trouve inefficace de publier ailleurs des images prêtes à l'emploi qui ne servent qu'à moi, pour ensuite les rapatrier en local. En plus dans la foulée de GitHub j'avais bien apprécié de supprimer mon compte Docker Hub enfin désactiver car la suppression ne semble pas possible :-(
|
||||
|
||||
En cherchant une alternative pour construire mes images localement et les transférer vers mon infra, j'ai pensé à Gitea qui a aussi la fonction de "docker registry" appelée *packages*. Je suis donc parti là dessus, une publication à la demande, pas de CI ; vu le peu d'activité des projets ça fait parfaitement l'affaire.
|
||||
|
||||
> C'est quand on paye de sa poche qu'on prend conscience de la valeur des choses
|
||||
|
||||
Après avoir mis ça en place... un changement d'URL dans 2 projets j'ai trouvé assez long la publication de l'image du blog dans Gitea alors que tout est sur le LAN. Et pour cause, elle faisait 1,2 Go alors que celle de stacosys tient dans 74 Mo. Évidemment, en tout automatisé dans une CI ça ne m'avait pas alerté auparavant. Me voilà donc à m'intéresser à un génial outil d'analyse d'images [Dive](https://github.com/wagoodman/dive) qu'on peut même exécuter en container,
|
||||
|
||||
alias dive="docker run -ti --rm -v /var/run/docker.sock:/var/run/docker.sock docker.io/wagoodman/dive"
|
||||
|
||||
à lire un peu de littérature sur les build multi-stages et à analyser où ça pêche... Bon, ça pêche à plusieurs endroits : trop de couches, installation d'outils de développement pour construire les locales MUSL de la distribution Alpine alors que sur les versions récentes elles sont proposés dans les dépôts. Après une phase d'optimisation j'ai pu ramener l'image à 128 Mo, honnête à mon sens pour un container avec Nginx, Python et des traitements Bash qui font du "git clone".
|
|
@ -1,15 +0,0 @@
|
|||
<!-- title: Zoraxy : changement de reverse-proxy -->
|
||||
<!-- category: Hébergement -->
|
||||
|
||||
J'ai décidé de remplacer [Nginx Proxy Manager](/2024/retour-en-auto-hebergement/) car la maintenance du container Alpine n'est pas aisée avec ma version modifiée pour ne pas exécuter les services du proxy en *root*. Ma première idée a été de revenir à une installation de Nginx classique, fournie par les dépôts Alpine, et à maintenir les fichiers de configuration manuellement. Après tout je l'ai fait pendant des années sur mes multiples installations de serveurs... Mais il semble que la flemme vienne avec l'âge : il y a le maintien de la configuration, la mise en place de [certbot](https://certbot.eff.org/), la surveillance et l'analyse des logs.
|
||||
|
||||
Je me suis demandé s'il y avait une alternative à Nginx Proxy Manager et je suis tombé sur [Zoraxy](https://zoraxy.aroz.org) : un outil tout-en-un qui fait office de reverse-proxy dynamique avec un tableau de bord d'analyse du trafic. Tout-en-un techniquement aussi car c'est une application écrite en Golang donc un binaire unique. En plus de la fonction de proxy HTTP et de la gestion des certificat, des fonctions additionnelles très sympathiques sont proposées via l'interface Web :
|
||||
- le support des proxy TCP / UDP (nommés Stream Proxy)
|
||||
- une surveillance de la disponibilité des services backends (uptime monitor)
|
||||
- une configuration des pages 404 et un hébergement de pages statiques. je n'ai pas expérimenté mais on doit pouvoir créer une page d'erreur personnalisée quand un service backend est par terre.
|
||||
- un tableau de bord assez complet qui permet de réagir et de contrôler les accès en bloquant des IP ou des pays
|
||||
|
||||
Devant l'engouement pour son projet le créateur de Zoraxy a recadré les utilisateurs en insistant sur le fait que Zoraxy ne pouvait pas avoir la robustesse et les performances d'un NginX et qu'il fallait le réserver à des usages plus limités. En cela il se positionne comme *"The ultimate homelab networking toolbox for self-hosted services"* et non pas comme un remplacement du reverse-proxy NginX pour des sites à gros volumes. Cela colle parfaitement à mon usage.
|
||||
|
||||
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<!-- title: Minimalisme et Debian sur laptop -->
|
||||
<!-- category: GNU/Linux -->
|
||||
|
||||
En complément de la tour PC sous Fedora + Win 11 Game Launcher (oui dans cet ordre-là) le laptop Tuxedo est mon fidèle poste mobile (à utiliser non loin d’une prise de courant car la batterie est fatiguée). Offert par un ami, c’est une sacrée machine propulsée par un Core i7 de 8ᵉ génération, 64Go de RAM et 2To de disque. Bref un monstre de puissance par rapport à mes usages, ce qui fait que les premières années j’ai multiplié les installations allant jusqu’à des triples boots pour tester tout ce qui me passait par la tête. Puis j’ai eu une période distancée (ou plutôt fatiguée) où j’avais juste besoin d’une machine opérationnelle et j’avais mis le classique Ubuntu 22.04 LTS. J’ai toujours craqué sur le look & feel léché d’Ubuntu et c’était une occasion de réinstaller cette distribution que j’ai beaucoup appréciée par le passé malgré quelques choix / partenariats discutables. Je l’ai conservé 2 ans avec beaucoup de râlerie au début, car la LTS portait mal la réputation de stabilité : incapable de mettre à jour ses flatpak sans mettre le nez en ligne de commande par exemple. Les bugs se sont corrigés au fil des mois puis je l’ai juste utilisée… même si je trouvais exagéré de proposer un flatpak par défaut pour des programmes de base comme le shell.
|
||||
|
||||
Et puis depuis quelques mois j’ai commencé un chantier qui s’apparente à un mix entre minimalisme et réduction des coûts : j’ai rapatrié le cloud de kDrive et tous mes petits services d’un VPS vers un mini-pc hébergé à la maison. Cela fait 6 mois et avec le recul (Trumpisme, augmentation des coûts des hébergeurs, incertitude de la régulation suisse) j’ai bien fait d’enlever mes billes d’Infomaniak et de reprendre la main sur mes données. J’ai aussi fermé quantité de comptes sur des sites (la plupart US) dont je pouvais me passer et migré mes projets GitHub (coucou Zaclys).
|
||||
|
||||
Le dernier clou fut les e-mails migrés d’Infomaniak vers [Ecomail](https://www.ecomail.fr/) en toute transparence car rattachés à mon nom de domaine. Là on n’est pas sur une réduction de coût, car la boite e-mail était offerte avec le nom de domaine mais sur une volonté d’en faire un peu plus pour la planète puisque Ecomail finance des actions avec une partie de son chiffre d’affaires. Ma *volonté* s’est bornée à effectuer un petit paiement ! Ce sont Clio et Nathan qui font le boulot et je pense qu’ils méritent d’être plus connus.
|
||||
|
||||
Tout ça pour en revenir à mon laptop et ma volonté de prolonger ce mouvement et d’agrandir ma surface de cohérence (et j’ai une grosse marge de progression). J’ai installé une Debian testing très vite renommée Trixie, quand j’ai validé qu’elle avait tout ce qu’il me fallait, afin de rester sur une version stable les deux ou trois prochaines années. Je n’ai jamais perdu Debian de vue et j’en installe souvent sur mes serveurs mais cela faisait très longtemps que je n’avais pas revu la version bureau. J’ai choisi Mate Desktop (nostalgie quand tu nous tiens) et un thème d’inspiration MacOs bien léché.
|
||||
|
||||

|
||||
|
||||
Que dire ? je l’adore et je me suis fixé des règles :
|
||||
- je limite les dépôts tiers ; là j’ai Sublime et Signal.
|
||||
- les Flatpak / AppImage sont proscrits.
|
||||
|
||||
Je commence même à apprécier Synaptic et son côté rétro… Sûrement l’indigestion de ces boutiques d’applications bourrées de publicité et de notation.
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<!-- title: GrapheneOS sur Pixel 6a -->
|
||||
<!-- category: Android -->
|
||||
|
||||
Le dernier article sur mon téléphone Android date de [2023](https://blogduyax.madyanne.fr/2023/applications-mobiles/) avec une liste des applications utilisées à l’époque. L’approche n’a pas changé ; je privilégie le magasin d’applications F-Droid en priorité même si je suis toujours dépendant de Google pour certains services. A sa sortie j’avais précommandé le Pixel 6a pour son rapport prix / qualité photographique, un critère majeur pour moi L’autre avantage de ce téléphone, à part ses mises à jour garanties pendant des années est l’absence de logiciels indésirables préinstallés et parfois impossibles à supprimer (les clones moisis de l’écosystème du fabricant pour des services standards chez Google par exemple ou la brochette d’applications sociales dont je n’ai rien à faire). Pour mes usages j’utilise le moins possible d’applications Google donc GMail, Drive, Photos étaient installés mais jamais lancés. Et à la place j’ai FairEmail, Seafile et Immich.
|
||||
|
||||
J’ai été satisfait du téléphone pendant trois ans, excepté quelques agacements récents suite au remplacement de l’assistant (désactivé de longue date) par Gemini qui a initié un jeu du chat et de la souris avec des réglages réinitialisés régulièrement par les mises à jour. Puis une conjonction de deux évènements ont déclenché le remplacement de la version Stock d’Android par GrapheneOS, une version durcie d’un point de vue sécurité et vie privée maintenue par une communauté très activée et financé par des sponsors individuels et des sociétés :
|
||||
|
||||
1. mon appareil est concerné par un problème de batterie défectueuse : Google m’a indemnisé de 100$ pour ce défaut
|
||||
2. un proche m’a posé des questions sur la dégooglisation des téléphones et nous avons discuté de la difficulté technique et des prérequis, idéalement peu d’adhérence aux services Google. Du coup j’ai continué cette réflexion pour mon propre cas en me demandant quelle était mon adhérence et si je pouvais aller plus loin.
|
||||
|
||||
J’ai identifié les applications GPS : même si j’utilise CoMaps essentiellement en vacances pour ses capacités hors ligne, j’utilise aussi beaucoup Maps et Waze pour l’info trafic sur la région marseillaise. On m’a conseillé Magic Earth en complément de CoMaps et si l’info trafic est forcément moins précise j’adore la clarté de sa navigation ; ça ressemble plus aux GPS dédiés de l’époque genre TomTom et moins à un jeu vidéo (on prend quoi ce mois-ci : un avatar de combi Volkswagen et les voix rigolotes de South Park ?). Je crois que je peux vivre sans une info trafic collaborative un peu trop temps réel qui pousse parfois à des décisions sur le vif qu’on regrette d’ailleurs en changeant d’itinéraire sans raison, ça me fera le plus grand bien. L’autre application nécessitant un compte est Youtube Music, un abonnement familial que je souhaite conserver encore quelques temps.
|
||||
|
||||
Les alternatives à la version Stock Android sur Pixel sont multiples : de la mama Lineage avec ou sans Gapps dont dérivent beaucoup de ROM custom et GrapheneOS qui sort du lot car :
|
||||
|
||||
- sa priorité est la sécurité et la confidentialité,
|
||||
- elle ne supporte que la gamme Pixel aujourd’hui, à priori, car ils ont une connaissance experte de ces modèles
|
||||
- son support optionnel des Play Services n’est pas une couche de compatibilité mais une version officielle en mode "bac à sable" : l’application Play Store n’est pas partie intégrante du système mais une application comme les autres
|
||||
- l’accès au PlayStore permet d’installer le module photo pour les Pixel et donc de conserver la qualité des photos produites par l’appareil
|
||||
|
||||
Selon la page Wikipedia, la fonction de sécurité introduit un accès réseau révocable et des paramètres d’autorisation des capteurs pour chaque application installée. Il introduit également une option de brouillage du code PIN pour l’écran de verrouillage. Une randomisation des adresses MAC Wi-Fi par connexion (à un réseau Wi-Fi) est introduite par défaut. Anecdote amusante je recevais une notification "nouvel appareil connecté au réseau" à chaque fois que je revenais à la maison à cause de cette randomisation des adresses MAC.
|
||||
|
||||
Étonnamment la méthode d’installation recommandée par GrapheneOS passe par un navigateur Web (privilégiez Chrome exceptionnellement) au lieu de manipulation en lignes de commandes avec ADB. Au préalable il faut déverrouiller le bootloader (et le verrouiller après l’installation) ; c’est une manipulation aisée et documentée par Google. Avec Xiaomi c’est mon second constructeur préféré pour cette partie qui peut-être risquée avec certains constructeurs qui ne supportent pas les utilisateurs changeant de système.
|
||||
|
||||
Je suis très satisfait de GrapheneOS. Les mises à jour du système sont poussées automatiquement et il suffit de redémarrer le téléphone pour les appliquer. J’ai reçu déjà trois mises à jour en quatre semaines. F-Droid reste mon magasin d’application privilégié ; j’aurais pu éviter le PlayStore et passer par Aurora, mais je ne suis pas très serein avec cette méthode. Et évidemment aucun assistant Google n’est installé et probablement installable sur ce système.
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<!-- title: Immich et les tags -->
|
||||
<!-- category: Hébergement -->
|
||||
|
||||
[Immich](https://immich.app/) est ma fierté de l'année. J'ai trouvé l'application de gestion de photos qui colle à ma façon de gérer, c'est à dire rester maître du stockage. Je continue à organiser mes photos par dossiers — un par année avec des noms communs d'une année sur l'autre comme "travaux" — et à gérer leur sauvegarde — dans mon cas c'est un combiné synchronisation Seafile entre mes machines et sauvegarde à froid sur un disque externe — et je donne un accès en lecture seule à Immich qui importe la photothèque dans sa base de donnée. Cela double peu ou prou le stockage mais je ne veux pas de dépendance et de risque de perte de données en cas de bug de l'appli de photos. Cela reste simple : des fichiers faciles à manipuler et à sauvegarder.
|
||||
|
||||
> Techniquement, Seafile et Immich sont exécutés par Docker : Seafile expose 100Go de photos via un partage fuse sur l'hôte et ce répertoire est monté en volume dans le container Immich.
|
||||
|
||||
Immich propose une vue chronologique, une recherche par géolocalisation, une recherche par personne et un recherche par contexte. La géolocalisation se base sur les données EXIF de la photo et les deux autres sur des LLM d'analyse qui tournent localement sur le serveur. La recherche par personne est redoutable : je ne l'ai pas pris en défaut avec des photos étalées sur une cinquantaine d'années.
|
||||
|
||||
Mais il me manquait une fonction que j'ai eu du mal à trouver : les étiquettes (ou tags). je voulais étiqueter mon répertoire "travaux" d'année en année et avoir un point d'entrée pour le parcourir, les noms de mes dossiers d'origine étant bien sûr ignorés par Immich. Après avoir traîné sur la doc officielle et les forums j'ai trouvé la méthode : Immich crée les tags automatiquement avec ce qu'il trouve dans les données EXIF des photos analysées. A vrai dire je ne savais pas qu'EXIF pouvait stocker cette information mais une propriété "keywords" existe pour cela. Vous noterez le "s" à keyword, on peut même associer plusieurs tags à une photo.
|
||||
|
||||
Après avoir installé *exiftool* disponible dans toutes les bonnes distributions j'ai pu modifier mes photos :
|
||||
|
||||
```
|
||||
# Afficher les étiquettes
|
||||
exiftool -Keywords
|
||||
|
||||
# Supprimer les étiquettes
|
||||
exiftool -Keywords=
|
||||
|
||||
# ajout d'une étiquette "Travaux"
|
||||
exiftool -Keywords=Travaux *.jpg
|
||||
```
|
||||
|
||||
Propriétés du fichier :
|
||||
|
||||

|
||||
|
||||
Et le lendemain après analyse par Immich mon étiquette est présente :
|
||||
|
||||

|
||||
|
|
@ -6,7 +6,7 @@ readme = "README.md"
|
|||
authors = [
|
||||
{ name = "Yax" }
|
||||
]
|
||||
requires-python = ">=3.12.9"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"mistune>=3.0.2",
|
||||
"pygments>=2.18.0",
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
vars: {
|
||||
d2-config: {
|
||||
layout-engine: elk
|
||||
# Terminal theme code
|
||||
theme-id: 300
|
||||
}
|
||||
}
|
||||
|
||||
dmz: {
|
||||
nginx proxy: {
|
||||
icon: https://icons.terrastruct.com/dev%2Fnginx.svg
|
||||
shape: rectangle
|
||||
}
|
||||
}
|
||||
|
||||
private network: {
|
||||
label.near: bottom-center
|
||||
virtual machines: {
|
||||
icon: https://icons.terrastruct.com/gcp%2FProducts%20and%20services%2FNetworking%2FVirtual%20Private%20Cloud.svg
|
||||
style.multiple: true
|
||||
containers: {
|
||||
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
|
||||
style.multiple: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lan: {
|
||||
bastion: {
|
||||
icon: https://icons.terrastruct.com/dev%2Fssh.svg
|
||||
}
|
||||
devices: {
|
||||
icon: https://icons.terrastruct.com/tech%2Flaptop.svg
|
||||
style.multiple: true
|
||||
}
|
||||
}
|
||||
|
||||
internet box: {
|
||||
icon: https://icons.terrastruct.com/tech%2Frouter.svg
|
||||
width: 130
|
||||
}
|
||||
|
||||
internet box -> dmz.nginx proxy: http/https
|
||||
|
||||
lan.bastion -> private network.virtual machines: ssh
|
||||
|
||||
dmz.nginx proxy -> private network.virtual machines: http
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 50 KiB |
|
@ -1,76 +0,0 @@
|
|||
vars: {
|
||||
d2-config: {
|
||||
layout-engine: elk
|
||||
# Terminal theme code
|
||||
theme-id: 4
|
||||
}
|
||||
}
|
||||
|
||||
Proxmox: {
|
||||
icon: https://www.svgrepo.com/download/331552/proxmox.svg
|
||||
label: "Hyperviseur Proxmox"
|
||||
|
||||
Containers: {
|
||||
label: "Containers LXC"
|
||||
|
||||
Nginx_Proxy: {
|
||||
icon: https://upload.wikimedia.org/wikipedia/commons/d/dd/Linux_Containers_logo.svg
|
||||
label: "Nginx Proxy"
|
||||
}
|
||||
|
||||
Bastion: {
|
||||
icon: https://upload.wikimedia.org/wikipedia/commons/d/dd/Linux_Containers_logo.svg
|
||||
label: "Bastion"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
vm1: {
|
||||
icon : https://upload.wikimedia.org/wikipedia/commons/thumb/7/70/Kvmbanner-logo2_1.png/320px-Kvmbanner-logo2_1.png
|
||||
label: "vm1"
|
||||
|
||||
Heimdall: {
|
||||
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
|
||||
label: "Heimdall"
|
||||
}
|
||||
|
||||
Blog: {
|
||||
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
|
||||
label: "Blog"
|
||||
}
|
||||
|
||||
Selfoss: {
|
||||
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
|
||||
label: "Selfoss"
|
||||
}
|
||||
|
||||
Wallabag: {
|
||||
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
|
||||
label: "Wallabag"
|
||||
}
|
||||
|
||||
Shaarli: {
|
||||
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
|
||||
label: "Shaarli"
|
||||
}
|
||||
}
|
||||
|
||||
vm2: {
|
||||
icon : https://upload.wikimedia.org/wikipedia/commons/thumb/7/70/Kvmbanner-logo2_1.png/320px-Kvmbanner-logo2_1.png
|
||||
label: "vm2"
|
||||
style: {
|
||||
stroke-dash: 3
|
||||
}
|
||||
|
||||
|
||||
Nextcloud: {
|
||||
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
|
||||
label: "Nextcloud"
|
||||
}
|
||||
|
||||
Immich: {
|
||||
icon: https://icons.terrastruct.com/dev%2Fdocker.svg
|
||||
label: "Immich"
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 177 KiB |
Binary file not shown.
Before Width: | Height: | Size: 96 KiB |
Binary file not shown.
Before Width: | Height: | Size: 105 KiB |
Binary file not shown.
Before Width: | Height: | Size: 431 KiB |
0
test/__init__.py
Normal file
0
test/__init__.py
Normal file
16
test/path.py
Normal file
16
test/path.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
|
||||
def temppath(*paths):
|
||||
return os.path.join(tempfile.gettempdir(), *paths)
|
||||
|
||||
|
||||
def move(src, dst):
|
||||
if os.path.isfile(dst):
|
||||
os.remove(dst)
|
||||
elif os.path.isdir(dst):
|
||||
shutil.rmtree(dst)
|
||||
if os.path.exists(src):
|
||||
os.rename(src, dst)
|
104
test/test_content.py
Normal file
104
test/test_content.py
Normal file
|
@ -0,0 +1,104 @@
|
|||
import unittest
|
||||
import shutil
|
||||
import os
|
||||
|
||||
import makesite
|
||||
from test import path
|
||||
|
||||
|
||||
class ContentTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.blog_path = path.temppath('blog')
|
||||
self.undated_path = os.path.join(self.blog_path, 'foo.txt')
|
||||
self.dated_path = os.path.join(self.blog_path, '2018-01-01-foo.txt')
|
||||
self.normal_post_path = os.path.join(self.blog_path, 'baz.txt')
|
||||
self.md_post_path = os.path.join(self.blog_path, 'qux.md')
|
||||
self.no_md_post_path = os.path.join(self.blog_path, 'qux.txt')
|
||||
|
||||
os.makedirs(self.blog_path)
|
||||
|
||||
with open(self.undated_path, 'w') as f:
|
||||
f.write('hello world')
|
||||
|
||||
with open(self.dated_path, 'w') as f:
|
||||
f.write('hello world')
|
||||
|
||||
with open(self.normal_post_path, 'w') as f:
|
||||
f.write('<!-- a: 1 -->\n<!-- b: 2 -->\nFoo')
|
||||
|
||||
with open(self.md_post_path, 'w') as f:
|
||||
f.write('*Foo*')
|
||||
|
||||
with open(self.no_md_post_path, 'w') as f:
|
||||
f.write('*Foo*')
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.blog_path)
|
||||
|
||||
# Rudimentary mock because unittest.mock is unavailable in Python 2.7.
|
||||
def mock(self, *args):
|
||||
self.mock_args = args
|
||||
|
||||
def test_content_content(self):
|
||||
content = makesite.read_content(self.undated_path)
|
||||
self.assertEqual(content['content'], 'hello world')
|
||||
|
||||
def test_content_date(self):
|
||||
content = makesite.read_content(self.dated_path)
|
||||
self.assertEqual(content['date'], '2018-01-01')
|
||||
|
||||
def test_content_date_missing(self):
|
||||
content = makesite.read_content(self.undated_path)
|
||||
self.assertEqual(content['date'], '1970-01-01')
|
||||
|
||||
def test_content_slug_dated(self):
|
||||
content = makesite.read_content(self.dated_path)
|
||||
self.assertEqual(content['slug'], 'foo')
|
||||
|
||||
def test_content_slug_undated(self):
|
||||
content = makesite.read_content(self.undated_path)
|
||||
self.assertEqual(content['slug'], 'foo')
|
||||
|
||||
def test_content_headers(self):
|
||||
content = makesite.read_content(self.normal_post_path)
|
||||
self.assertEqual(content['a'], '1')
|
||||
self.assertEqual(content['b'], '2')
|
||||
self.assertEqual(content['content'], 'Foo')
|
||||
|
||||
def test_markdown_rendering(self):
|
||||
content = makesite.read_content(self.md_post_path)
|
||||
self.assertEqual(content['content'], '<p><em>Foo</em></p>\n')
|
||||
|
||||
def test_markdown_import_error(self):
|
||||
makesite._test = 'ImportError'
|
||||
original_log = makesite.log
|
||||
|
||||
makesite.log = self.mock
|
||||
self.mock_args = None
|
||||
content = makesite.read_content(self.md_post_path)
|
||||
|
||||
makesite._test = None
|
||||
makesite.log = original_log
|
||||
|
||||
self.assertEqual(content['content'], '*Foo*')
|
||||
self.assertEqual(self.mock_args,
|
||||
('WARNING: Cannot render Markdown in {}: {}',
|
||||
self.md_post_path, 'Error forced by test'))
|
||||
|
||||
def test_no_markdown_rendering(self):
|
||||
content = makesite.read_content(self.no_md_post_path)
|
||||
self.assertEqual(content['content'], '*Foo*')
|
||||
|
||||
def test_no_markdown_import_error(self):
|
||||
makesite._test = 'ImportError'
|
||||
original_log = makesite.log
|
||||
|
||||
makesite.log = self.mock
|
||||
self.mock_args = None
|
||||
content = makesite.read_content(self.no_md_post_path)
|
||||
|
||||
makesite._test = None
|
||||
makesite.log = original_log
|
||||
|
||||
self.assertEqual(content['content'], '*Foo*')
|
||||
self.assertIsNone(self.mock_args)
|
39
test/test_file_io.py
Normal file
39
test/test_file_io.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
import unittest
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import makesite
|
||||
from test import path
|
||||
|
||||
|
||||
class FileIOTest(unittest.TestCase):
|
||||
"""Tests for file I/O functions."""
|
||||
|
||||
def test_fread(self):
|
||||
text = 'foo\nbar\n'
|
||||
filepath = path.temppath('foo.txt')
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(text)
|
||||
text_read = makesite.fread(filepath)
|
||||
os.remove(filepath)
|
||||
self.assertEqual(text_read, text)
|
||||
|
||||
def test_fwrite(self):
|
||||
text = 'baz\nqux\n'
|
||||
filepath = path.temppath('foo.txt')
|
||||
makesite.fwrite(filepath, text)
|
||||
with open(filepath) as f:
|
||||
text_read = f.read()
|
||||
os.remove(filepath)
|
||||
self.assertEqual(text_read, text)
|
||||
|
||||
def test_fwrite_makedir(self):
|
||||
text = 'baz\nqux\n'
|
||||
dirpath = path.temppath('foo', 'bar')
|
||||
filepath = os.path.join(dirpath, 'foo.txt')
|
||||
makesite.fwrite(filepath, text)
|
||||
with open(filepath) as f:
|
||||
text_read = f.read()
|
||||
self.assertTrue(os.path.isdir(dirpath))
|
||||
shutil.rmtree(path.temppath('foo'))
|
||||
self.assertEqual(text_read, text)
|
43
test/test_headers.py
Normal file
43
test/test_headers.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
import unittest
|
||||
import makesite
|
||||
|
||||
|
||||
class HeaderTest(unittest.TestCase):
|
||||
"""Tests for read_headers() function."""
|
||||
|
||||
def test_single_header(self):
|
||||
text = '<!-- key1: val1 -->'
|
||||
headers = list(makesite.read_headers(text))
|
||||
self.assertEqual(headers, [('key1', 'val1', 19)])
|
||||
|
||||
def test_multiple_headers(self):
|
||||
text = '<!-- key1: val1 -->\n<!-- key2: val2-->'
|
||||
headers = list(makesite.read_headers(text))
|
||||
self.assertEqual(headers, [('key1', 'val1', 20), ('key2', 'val2', 38)])
|
||||
|
||||
def test_headers_and_text(self):
|
||||
text = '<!-- a: 1 -->\n<!-- b: 2 -->\nFoo\n<!-- c: 3 -->'
|
||||
headers = list(makesite.read_headers(text))
|
||||
self.assertEqual(headers, [('a', '1', 14), ('b', '2', 28)])
|
||||
|
||||
def test_headers_and_blank_line(self):
|
||||
text = '<!-- a: 1 -->\n<!-- b: 2 -->\n\n<!-- c: 3 -->\n'
|
||||
headers = list(makesite.read_headers(text))
|
||||
self.assertEqual(headers, [('a', '1', 14),
|
||||
('b', '2', 29),
|
||||
('c', '3', 43)])
|
||||
|
||||
def test_multiline_header(self):
|
||||
text = '<!--\na: 1 --><!-- b:\n2 -->\n<!-- c: 3\n-->'
|
||||
headers = list(makesite.read_headers(text))
|
||||
self.assertEqual(headers, [('a', '1', 13),
|
||||
('b', '2', 27),
|
||||
('c', '3', 40)])
|
||||
|
||||
def test_no_header(self):
|
||||
headers = list(makesite.read_headers('Foo'))
|
||||
self.assertEqual(headers, [])
|
||||
|
||||
def test_empty_string(self):
|
||||
headers = list(makesite.read_headers(''))
|
||||
self.assertEqual(headers, [])
|
46
test/test_list.py
Normal file
46
test/test_list.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
import unittest
|
||||
import shutil
|
||||
import os
|
||||
import makesite
|
||||
from test import path
|
||||
|
||||
class PagesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.site_path = path.temppath('site')
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.site_path)
|
||||
|
||||
def test_list(self):
|
||||
posts = [{'content': 'Foo'}, {'content': 'Bar'}]
|
||||
dst = os.path.join(self.site_path, 'list.txt')
|
||||
list_layout = '<div>{{ content }}</div>'
|
||||
item_layout = '<p>{{ content }}</p>'
|
||||
makesite.make_list(posts, dst, list_layout, item_layout)
|
||||
with open(os.path.join(self.site_path, 'list.txt')) as f:
|
||||
self.assertEqual(f.read(), '<div><p>Foo</p><p>Bar</p></div>')
|
||||
|
||||
def test_list_params(self):
|
||||
posts = [{'content': 'Foo', 'title': 'foo'},
|
||||
{'content': 'Bar', 'title': 'bar'}]
|
||||
dst = os.path.join(self.site_path, 'list.txt')
|
||||
list_layout = '<div>{{ key }}:{{ title }}:{{ content }}</div>'
|
||||
item_layout = '<p>{{ key }}:{{ title }}:{{ content }}</p>'
|
||||
makesite.make_list(posts, dst, list_layout, item_layout,
|
||||
key='val', title='lorem')
|
||||
with open(os.path.join(self.site_path, 'list.txt')) as f:
|
||||
text = f.read()
|
||||
self.assertEqual(text,
|
||||
'<div>val:lorem:<p>val:foo:Foo</p><p>val:bar:Bar</p></div>')
|
||||
|
||||
def test_dst_params(self):
|
||||
posts = [{'content': 'Foo'}, {'content': 'Bar'}]
|
||||
dst = os.path.join(self.site_path, '{{ key }}.txt')
|
||||
list_layout = '<div>{{ content }}</div>'
|
||||
item_layout = '<p>{{ content }}</p>'
|
||||
makesite.make_list(posts, dst, list_layout, item_layout, key='val')
|
||||
|
||||
expected_path = os.path.join(self.site_path, 'val.txt')
|
||||
self.assertTrue(os.path.isfile(expected_path))
|
||||
with open(expected_path) as f:
|
||||
self.assertEqual(f.read(), '<div><p>Foo</p><p>Bar</p></div>')
|
73
test/test_main.py
Normal file
73
test/test_main.py
Normal file
|
@ -0,0 +1,73 @@
|
|||
import unittest
|
||||
import makesite
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
|
||||
from test import path
|
||||
|
||||
|
||||
class MainTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
path.move('_site', '_site.backup')
|
||||
path.move('params.json', 'params.json.backup')
|
||||
|
||||
def tearDown(self):
|
||||
path.move('_site.backup', '_site')
|
||||
path.move('params.json.backup', 'params')
|
||||
|
||||
def test_site_missing(self):
|
||||
makesite.main()
|
||||
|
||||
def test_site_exists(self):
|
||||
os.mkdir('_site')
|
||||
with open('_site/foo.txt', 'w') as f:
|
||||
f.write('foo')
|
||||
|
||||
self.assertTrue(os.path.isfile('_site/foo.txt'))
|
||||
makesite.main()
|
||||
self.assertFalse(os.path.isfile('_site/foo.txt'))
|
||||
|
||||
def test_default_params(self):
|
||||
makesite.main()
|
||||
|
||||
with open('_site/blog/proin-quam/index.html') as f:
|
||||
s1 = f.read()
|
||||
|
||||
with open('_site/blog/rss.xml') as f:
|
||||
s2 = f.read()
|
||||
|
||||
shutil.rmtree('_site')
|
||||
|
||||
self.assertIn('<a href="/">Home</a>', s1)
|
||||
self.assertIn('<title>Proin Quam - Lorem Ipsum</title>', s1)
|
||||
self.assertIn('Published on 2018-01-01 by <b>Admin</b>', s1)
|
||||
|
||||
self.assertIn('<link>http://localhost:8000/</link>', s2)
|
||||
self.assertIn('<link>http://localhost:8000/blog/proin-quam/</link>', s2)
|
||||
|
||||
def test_json_params(self):
|
||||
params = {
|
||||
'base_path': '/base',
|
||||
'subtitle': 'Foo',
|
||||
'author': 'Bar',
|
||||
'site_url': 'http://localhost/base'
|
||||
}
|
||||
with open('params.json', 'w') as f:
|
||||
json.dump(params, f)
|
||||
makesite.main()
|
||||
|
||||
with open('_site/blog/proin-quam/index.html') as f:
|
||||
s1 = f.read()
|
||||
|
||||
with open('_site/blog/rss.xml') as f:
|
||||
s2 = f.read()
|
||||
|
||||
shutil.rmtree('_site')
|
||||
|
||||
self.assertIn('<a href="/base/">Home</a>', s1)
|
||||
self.assertIn('<title>Proin Quam - Foo</title>', s1)
|
||||
self.assertIn('Published on 2018-01-01 by <b>Bar</b>', s1)
|
||||
|
||||
self.assertIn('<link>http://localhost/base/</link>', s2)
|
||||
self.assertIn('<link>http://localhost/base/blog/proin-quam/</link>', s2)
|
127
test/test_pages.py
Normal file
127
test/test_pages.py
Normal file
|
@ -0,0 +1,127 @@
|
|||
import unittest
|
||||
import os
|
||||
import shutil
|
||||
import makesite
|
||||
from test import path
|
||||
|
||||
class PagesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.blog_path = path.temppath('blog')
|
||||
self.site_path = path.temppath('site')
|
||||
os.makedirs(self.blog_path)
|
||||
|
||||
with open(os.path.join(self.blog_path, 'foo.txt'), 'w') as f:
|
||||
f.write('Foo')
|
||||
with open(os.path.join(self.blog_path, 'bar.txt'), 'w') as f:
|
||||
f.write('Bar')
|
||||
with open(os.path.join(self.blog_path, '2018-01-01-foo.txt'), 'w') as f:
|
||||
f.write('Foo')
|
||||
with open(os.path.join(self.blog_path, '2018-01-02-bar.txt'), 'w') as f:
|
||||
f.write('Bar')
|
||||
with open(os.path.join(self.blog_path, 'header-foo.txt'), 'w') as f:
|
||||
f.write('<!-- tag: foo -->Foo')
|
||||
with open(os.path.join(self.blog_path, 'header-bar.txt'), 'w') as f:
|
||||
f.write('<!-- title: bar -->Bar')
|
||||
with open(os.path.join(self.blog_path, 'placeholder-foo.txt'), 'w') as f:
|
||||
f.write('<!-- title: foo -->{{ title }}:{{ author }}:Foo')
|
||||
with open(os.path.join(self.blog_path, 'placeholder-bar.txt'), 'w') as f:
|
||||
f.write('<!-- title: bar --><!-- render: yes -->{{ title }}:{{ author }}:Bar')
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.blog_path)
|
||||
shutil.rmtree(self.site_path)
|
||||
|
||||
def test_pages_undated(self):
|
||||
src = os.path.join(self.blog_path, '[fb]*.txt')
|
||||
dst = os.path.join(self.site_path, '{{ slug }}.txt')
|
||||
tpl = '<div>{{ content }}</div>'
|
||||
makesite.make_pages(src, dst, tpl)
|
||||
with open(os.path.join(self.site_path, 'foo.txt')) as f:
|
||||
self.assertEqual(f.read(), '<div>Foo</div>')
|
||||
with open(os.path.join(self.site_path, 'bar.txt')) as f:
|
||||
self.assertEqual(f.read(), '<div>Bar</div>')
|
||||
|
||||
def test_pages_dated(self):
|
||||
src = os.path.join(self.blog_path, '2*.txt')
|
||||
dst = os.path.join(self.site_path, '{{ slug }}.txt')
|
||||
tpl = '<div>{{ content }}</div>'
|
||||
makesite.make_pages(src, dst, tpl)
|
||||
with open(os.path.join(self.site_path, 'foo.txt')) as f:
|
||||
self.assertEqual(f.read(), '<div>Foo</div>')
|
||||
with open(os.path.join(self.site_path, 'bar.txt')) as f:
|
||||
self.assertEqual(f.read(), '<div>Bar</div>')
|
||||
|
||||
def test_pages_layout_params(self):
|
||||
src = os.path.join(self.blog_path, '2*.txt')
|
||||
dst = os.path.join(self.site_path, '{{ slug }}.txt')
|
||||
tpl = '<div>{{ slug }}:{{ title }}:{{ date }}:{{ content }}</div>'
|
||||
makesite.make_pages(src, dst, tpl, title='Lorem')
|
||||
with open(os.path.join(self.site_path, 'foo.txt')) as f:
|
||||
self.assertEqual(f.read(), '<div>foo:Lorem:2018-01-01:Foo</div>')
|
||||
with open(os.path.join(self.site_path, 'bar.txt')) as f:
|
||||
self.assertEqual(f.read(), '<div>bar:Lorem:2018-01-02:Bar</div>')
|
||||
|
||||
def test_pages_return_value(self):
|
||||
src = os.path.join(self.blog_path, '2*.txt')
|
||||
dst = os.path.join(self.site_path, '{{ slug }}.txt')
|
||||
tpl = '<div>{{ content }}</div>'
|
||||
posts = makesite.make_pages(src, dst, tpl)
|
||||
self.assertEqual(len(posts), 2)
|
||||
self.assertEqual(posts[0]['date'], '2018-01-02')
|
||||
self.assertEqual(posts[1]['date'], '2018-01-01')
|
||||
|
||||
def test_content_header_params(self):
|
||||
# Test that header params from one post is not used in another
|
||||
# post.
|
||||
src = os.path.join(self.blog_path, 'header*.txt')
|
||||
dst = os.path.join(self.site_path, '{{ slug }}.txt')
|
||||
tpl = '{{ title }}:{{ tag }}:{{ content }}'
|
||||
makesite.make_pages(src, dst, tpl)
|
||||
with open(os.path.join(self.site_path, 'header-foo.txt')) as f:
|
||||
self.assertEqual(f.read(), '{{ title }}:foo:Foo')
|
||||
with open(os.path.join(self.site_path, 'header-bar.txt')) as f:
|
||||
self.assertEqual(f.read(), 'bar:{{ tag }}:Bar')
|
||||
|
||||
def test_content_no_rendering(self):
|
||||
# Test that placeholders are not populated in content rendering
|
||||
# by default.
|
||||
src = os.path.join(self.blog_path, 'placeholder-foo.txt')
|
||||
dst = os.path.join(self.site_path, '{{ slug }}.txt')
|
||||
tpl = '<div>{{ content }}</div>'
|
||||
makesite.make_pages(src, dst, tpl, author='Admin')
|
||||
with open(os.path.join(self.site_path, 'placeholder-foo.txt')) as f:
|
||||
self.assertEqual(f.read(), '<div>{{ title }}:{{ author }}:Foo</div>')
|
||||
|
||||
def test_content_rendering_via_kwargs(self):
|
||||
# Test that placeholders are populated in content rendering when
|
||||
# requested in make_pages.
|
||||
src = os.path.join(self.blog_path, 'placeholder-foo.txt')
|
||||
dst = os.path.join(self.site_path, '{{ slug }}.txt')
|
||||
tpl = '<div>{{ content }}</div>'
|
||||
makesite.make_pages(src, dst, tpl, author='Admin', render='yes')
|
||||
with open(os.path.join(self.site_path, 'placeholder-foo.txt')) as f:
|
||||
self.assertEqual(f.read(), '<div>foo:Admin:Foo</div>')
|
||||
|
||||
def test_content_rendering_via_header(self):
|
||||
# Test that placeholders are populated in content rendering when
|
||||
# requested in content header.
|
||||
src = os.path.join(self.blog_path, 'placeholder-bar.txt')
|
||||
dst = os.path.join(self.site_path, '{{ slug }}.txt')
|
||||
tpl = '<div>{{ content }}</div>'
|
||||
makesite.make_pages(src, dst, tpl, author='Admin')
|
||||
with open(os.path.join(self.site_path, 'placeholder-bar.txt')) as f:
|
||||
self.assertEqual(f.read(), '<div>bar:Admin:Bar</div>')
|
||||
|
||||
def test_rendered_content_in_summary(self):
|
||||
# Test that placeholders are populated in summary if and only if
|
||||
# content rendering is enabled.
|
||||
src = os.path.join(self.blog_path, 'placeholder*.txt')
|
||||
post_dst = os.path.join(self.site_path, '{{ slug }}.txt')
|
||||
list_dst = os.path.join(self.site_path, 'list.txt')
|
||||
post_layout = ''
|
||||
list_layout = '<div>{{ content }}</div>'
|
||||
item_layout = '<p>{{ summary }}</p>'
|
||||
posts = makesite.make_pages(src, post_dst, post_layout, author='Admin')
|
||||
makesite.make_list(posts, list_dst, list_layout, item_layout)
|
||||
with open(os.path.join(self.site_path, 'list.txt')) as f:
|
||||
self.assertEqual(f.read(), '<div><p>{{ title }}:{{ author }}:Foo</p><p>bar:Admin:Bar</p></div>')
|
78
test/test_path.py
Normal file
78
test/test_path.py
Normal file
|
@ -0,0 +1,78 @@
|
|||
import unittest
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from test import path
|
||||
|
||||
class PathTest(unittest.TestCase):
|
||||
def test_temppath(self):
|
||||
self.assertTrue(path.temppath())
|
||||
|
||||
def test_move_existing_file(self):
|
||||
src = os.path.join(path.temppath(), 'foo.txt')
|
||||
dst = os.path.join(path.temppath(), 'bar.txt')
|
||||
with open(src, 'w') as f:
|
||||
f.write('foo')
|
||||
|
||||
path.move(src, dst)
|
||||
self.assertFalse(os.path.isfile(src))
|
||||
self.assertTrue(os.path.isfile(dst))
|
||||
|
||||
with open(dst) as f:
|
||||
text = f.read()
|
||||
|
||||
os.remove(dst)
|
||||
|
||||
self.assertEqual(text, 'foo')
|
||||
|
||||
def test_move_missing_file(self):
|
||||
src = os.path.join(path.temppath(), 'foo.txt')
|
||||
dst = os.path.join(path.temppath(), 'bar.txt')
|
||||
path.move(src, dst)
|
||||
self.assertFalse(os.path.isfile(src))
|
||||
self.assertFalse(os.path.isfile(dst))
|
||||
|
||||
def test_move_file_cleanup(self):
|
||||
src = os.path.join(path.temppath(), 'foo.txt')
|
||||
dst = os.path.join(path.temppath(), 'bar.txt')
|
||||
with open(dst, 'w') as f:
|
||||
f.write('foo')
|
||||
path.move(src, dst)
|
||||
self.assertFalse(os.path.isfile(src))
|
||||
self.assertFalse(os.path.isfile(dst))
|
||||
|
||||
def test_move_existing_dir(self):
|
||||
src = os.path.join(path.temppath(), 'foo')
|
||||
srcf = os.path.join(src, 'foo.txt')
|
||||
dst = os.path.join(path.temppath(), 'bar')
|
||||
dstf = os.path.join(dst, 'foo.txt')
|
||||
|
||||
os.makedirs(src)
|
||||
with open(srcf, 'w') as f:
|
||||
f.write('foo')
|
||||
|
||||
path.move(src, dst)
|
||||
self.assertFalse(os.path.isdir(src))
|
||||
self.assertTrue(os.path.isdir(dst))
|
||||
|
||||
with open(dstf) as f:
|
||||
text = f.read()
|
||||
|
||||
shutil.rmtree(dst)
|
||||
|
||||
self.assertEqual(text, 'foo')
|
||||
|
||||
def test_move_missing_dir(self):
|
||||
src = os.path.join(path.temppath(), 'foo')
|
||||
dst = os.path.join(path.temppath(), 'bar')
|
||||
path.move(src, dst)
|
||||
self.assertFalse(os.path.isdir(src))
|
||||
self.assertFalse(os.path.isdir(dst))
|
||||
|
||||
def test_move_dir_cleanup(self):
|
||||
src = os.path.join(path.temppath(), 'foo')
|
||||
dst = os.path.join(path.temppath(), 'bar')
|
||||
os.makedirs(dst)
|
||||
path.move(src, dst)
|
||||
self.assertFalse(os.path.isdir(src))
|
||||
self.assertFalse(os.path.isdir(dst))
|
25
test/test_render.py
Normal file
25
test/test_render.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
import unittest
|
||||
import makesite
|
||||
|
||||
class RenderTest(unittest.TestCase):
|
||||
"""Tests for render() function."""
|
||||
|
||||
def test_oneline_template(self):
|
||||
tpl = 'foo {{ key1 }} baz {{ key2 }}'
|
||||
out = makesite.render(tpl, key1='bar', key2='qux')
|
||||
self.assertEqual(out, 'foo bar baz qux')
|
||||
|
||||
def test_multiline_template(self):
|
||||
tpl = 'foo {{ key1 }}\nbaz {{ key1 }}'
|
||||
out = makesite.render(tpl, key1='bar')
|
||||
self.assertEqual(out, 'foo bar\nbaz bar')
|
||||
|
||||
def test_repeated_key(self):
|
||||
tpl = 'foo {{ key1 }} baz {{ key1 }}'
|
||||
out = makesite.render(tpl, key1='bar')
|
||||
self.assertEqual(out, 'foo bar baz bar')
|
||||
|
||||
def test_multiline_placeholder(self):
|
||||
tpl = 'foo {{\nkey1\n}} baz {{\nkey2\n}}'
|
||||
out = makesite.render(tpl, key1='bar', key2='qux')
|
||||
self.assertEqual(out, 'foo bar baz qux')
|
13
test/test_rfc_2822_date.py
Normal file
13
test/test_rfc_2822_date.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
import unittest
|
||||
import makesite
|
||||
|
||||
|
||||
class RFC822DateTest(unittest.TestCase):
|
||||
|
||||
def test_epoch(self):
|
||||
self.assertEqual(makesite.rfc_2822_format('1970-01-01'),
|
||||
'Thu, 01 Jan 1970 00:00:00 +0000')
|
||||
|
||||
def test_2018_06_16(self):
|
||||
self.assertEqual(makesite.rfc_2822_format('2018-06-16'),
|
||||
'Sat, 16 Jun 2018 00:00:00 +0000')
|
9
test/test_truncate.py
Normal file
9
test/test_truncate.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
import unittest
|
||||
import makesite
|
||||
|
||||
|
||||
class TruncateTest(unittest.TestCase):
|
||||
def test_truncate(self):
|
||||
long_text = ' \n'.join('word' + str(i) for i in range(50))
|
||||
expected_text = ' '.join('word' + str(i) for i in range(25))
|
||||
self.assertEqual(makesite.truncate(long_text), expected_text)
|
188
uv.lock
generated
Normal file
188
uv.lock
generated
Normal file
|
@ -0,0 +1,188 @@
|
|||
version = 1
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "24.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/0d/cc2fb42b8c50d80143221515dd7e4766995bd07c56c9a3ed30baf080b6dc/black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", size = 645813 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a0/a993f58d4ecfba035e61fca4e9f64a2ecae838fc9f33ab798c62173ed75c/black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", size = 1643986 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/d5/602d0ef5dfcace3fb4f79c436762f130abd9ee8d950fa2abdbf8bbc555e0/black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", size = 1448085 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/6d/a3a239e938960df1a662b93d6230d4f3e9b4a22982d060fc38c42f45a56b/black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", size = 1760928 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/cf/af018e13b0eddfb434df4d9cd1b2b7892bab119f7a20123e93f6910982e8/black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", size = 1436875 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/a7/4b27c50537ebca8bec139b872861f9d2bf501c5ec51fcf897cb924d9e264/black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", size = 206898 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blog"
|
||||
version = "1.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "mistune" },
|
||||
{ name = "pygments" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "black" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "mistune", specifier = ">=3.0.2" },
|
||||
{ name = "pygments", specifier = ">=2.18.0" },
|
||||
{ name = "requests", specifier = ">=2.32.3" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "black", specifier = ">=24.10.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2024.8.30"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "platform_system == 'Windows'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mistune"
|
||||
version = "3.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/c8/f0173fe3bf85fd891aee2e7bcd8207dfe26c2c683d727c5a6cc3aec7b628/mistune-3.0.2.tar.gz", hash = "sha256:fc7f93ded930c92394ef2cb6f04a8aabab4117a91449e72dcc8dfa646a508be8", size = 90840 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/74/c95adcdf032956d9ef6c89a9b8a5152bf73915f8c633f3e3d88d06bd699c/mistune-3.0.2-py3-none-any.whl", hash = "sha256:71481854c30fdbc938963d3605b72501f5c10a9320ecd412c121c163a1c7d205", size = 47958 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "24.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.3.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.18.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 },
|
||||
]
|
Loading…
Add table
Reference in a new issue