Compare commits

..

No commits in common. "main" and "2.1-current" have entirely different histories.

69 changed files with 2396 additions and 2419 deletions

14
.github/workflows/docker.yml vendored Normal file
View file

@ -0,0 +1,14 @@
name: docker
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- 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

31
.github/workflows/pytest.yml vendored Normal file
View file

@ -0,0 +1,31 @@
name: pytest
on: push
jobs:
ci:
strategy:
fail-fast: false
matrix:
python-version: [3.9, 3.9.6]
poetry-version: [1.1.7]
os: [ubuntu-18.04, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Run image
uses: abatilo/actions-poetry@v2.0.0
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Install dependencies
run: poetry install
- name: Pytest and Coverage
run: |
poetry run coverage run -m --source=stacosys pytest tests
poetry run coverage report
- name: Send report to Coveralls
run: poetry run coveralls
env:
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}

11
.gitignore vendored
View file

@ -27,7 +27,7 @@ var/
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
#*.spec
*.spec
# Installer logs
pip-log.txt
@ -62,6 +62,9 @@ db.sqlite
db.json
node_modules
comments.xml
stacosys/bin/
stacosys/pyvenv.cfg
stacosys/lib64
.vscode/
.pytest_cache/
workspace.code-workspace
@ -69,9 +72,3 @@ workspace.code-workspace
config-server.ini
config-dev.ini
.idea/
.python-version
stacosys.sublime-project
stacosys.sublime-workspace
out/
junit.xml
coverage.xml

622
.pylintrc
View file

@ -1,622 +0,0 @@
[MAIN]
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# Load and enable all available extensions. Use --list-extensions to see a list
# all available extensions.
#enable-all-extensions=
# In error mode, messages with a category besides ERROR or FATAL are
# suppressed, and no reports are done by default. Error mode is compatible with
# disabling specific errors.
#errors-only=
# Always return a 0 (non-error) status code, even if lint errors are found.
# This is primarily useful in continuous integration scripts.
#exit-zero=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
# for backward compatibility.)
extension-pkg-whitelist=
# Return non-zero exit code if any of these messages/categories are detected,
# even if score is above --fail-under value. Syntax same as enable. Messages
# specified are enabled, while categories only check already-enabled messages.
fail-on=
# Specify a score threshold under which the program will exit with error.
fail-under=10
# Interpret the stdin as a python script, whose filename needs to be passed as
# the module_or_package argument.
#from-stdin=
# Files or directories to be skipped. They should be base names, not paths.
ignore=CVS
# Add files or directories matching the regular expressions patterns to the
# ignore-list. The regex matches against paths and can be in Posix or Windows
# format. Because '\' represents the directory delimiter on Windows systems, it
# can't be used as an escape character.
ignore-paths=
# Files or directories matching the regular expression patterns are skipped.
# The regex matches against base names, not paths. The default value ignores
# Emacs file locks
ignore-patterns=^\.#
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis). It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use, and will cap the count on Windows to
# avoid hangs.
jobs=1
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
# complex, nested conditions.
limit-inference-results=100
# List of plugins (as comma separated values of python module names) to load,
# usually to register additional checkers.
load-plugins=
# Pickle collected data for later comparisons.
persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.11
# Discover python modules and packages in the file system subtree.
recursive=no
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
# In verbose mode, extra non-checker-related info will be displayed.
#verbose=
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Bad variable names regexes, separated by a comma. If names match any regex,
# they will always be refused
bad-names-rgxs=
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
#class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
#class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_
# Good variable names regexes, separated by a comma. If names match any regex,
# they will always be accepted
good-names-rgxs=
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style. If left empty, inline iteration names will be checked
# with the set naming style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Regular expression matching correct type variable names. If left empty, type
# variable names will be checked with the set naming style.
#typevar-rgx=
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
#variable-rgx=
[CLASSES]
# Warn about protected attribute access inside special methods
check-protected-access-in-special-methods=no
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp,
__post_init__
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,
_fields,
_replace,
_source,
_make
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=cls
[DESIGN]
# List of regular expressions of class ancestor names to ignore when counting
# public methods (see R0903)
exclude-too-few-public-methods=
# List of qualified class names to ignore when counting class parents (see
# R0901)
ignored-parents=
# Maximum number of arguments for function / method.
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Maximum number of boolean expressions in an if statement (see R0916).
max-bool-expr=5
# Maximum number of branch for function / method body.
max-branches=12
# Maximum number of locals for function / method body.
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of return / yield for function / method body.
max-returns=6
# Maximum number of statements in function / method body.
max-statements=50
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
[EXCEPTIONS]
# Exceptions that will emit a warning when caught.
overgeneral-exceptions=builtins.BaseException,
builtins.Exception
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=100
# Maximum number of lines in a module.
max-module-lines=1000
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[IMPORTS]
# List of modules that can be imported at any level, not just the top level
# one.
allow-any-import-level=
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Deprecated modules which should not be used, separated by a comma.
deprecated-modules=
# Output a graph (.gv or any supported image format) of external dependencies
# to the given file (report RP0402 must not be disabled).
ext-import-graph=
# Output a graph (.gv or any supported image format) of all (i.e. internal and
# external) dependencies to the given file (report RP0402 must not be
# disabled).
import-graph=
# Output a graph (.gv or any supported image format) of internal dependencies
# to the given file (report RP0402 must not be disabled).
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
# Couples of modules and preferred modules, separated by a comma.
preferred-modules=
[LOGGING]
# The type of string formatting that logging methods do. `old` means using %
# formatting, `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
# UNDEFINED.
confidence=HIGH,
CONTROL_FLOW,
INFERENCE,
INFERENCE_FAILURE,
UNDEFINED
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then re-enable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W".
disable=raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead,
missing-module-docstring,
missing-class-docstring,
missing-function-docstring,
too-few-public-methods
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=c-extension-no-member
[METHOD_ARGS]
# List of qualified names (i.e., library.method) which require a timeout
# parameter e.g. 'requests.api.get,requests.api.post'
timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
# Regular expression of note tags to take in consideration.
notes-rgx=
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit,argparse.parse_error
[REPORTS]
# Python expression which should return a score less than or equal to 10. You
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
# 'convention', and 'info' which contain the number of messages in each
# category, as well as 'statement' which is the total number of statements
# analyzed. This score is used by the global evaluation report (RP0004).
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details.
msg-template=
# Set the output format. Available formats are text, parseable, colorized, json
# and msvs (visual studio). You can also give a reporter class, e.g.
# mypackage.mymodule.MyReporterClass.
#output-format=
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[SIMILARITIES]
# Comments are removed from the similarity computation
ignore-comments=yes
# Docstrings are removed from the similarity computation
ignore-docstrings=yes
# Imports are removed from the similarity computation
ignore-imports=yes
# Signatures are removed from the similarity computation
ignore-signatures=yes
# Minimum lines number of a similarity.
min-similarity-lines=4
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Spelling dictionary name. Available dictionaries: none. To make it work,
# install the 'python-enchant' package.
spelling-dict=
# List of comma separated words that should be considered directives if they
# appear at the beginning of a comment and should not be checked.
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains the private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to the private dictionary (see the
# --spelling-private-dict-file option) instead of raising a message.
spelling-store-unknown-words=no
[STRING]
# This flag controls whether inconsistent-quotes generates a warning when the
# character used as a quote delimiter is used inconsistently within a module.
check-quote-consistency=no
# This flag controls whether the implicit-str-concat should generate a warning
# on implicit string concatenation in sequences defined over several lines.
check-str-concat-over-line-jumps=no
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=
# Tells whether to warn about missing members when the owner of the attribute
# is inferred to be None.
ignore-none=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of symbolic message names to ignore for Mixin members.
ignored-checks-for-mixins=no-member,
not-async-context-manager,
not-context-manager,
attribute-defined-outside-init
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
# Regex pattern to define which classes are considered mixins.
mixin-class-rgx=.*[Mm]ixin
# List of decorators that change the signature of a decorated function.
signature-mutators=
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of names allowed to shadow builtins
allowed-redefined-builtins=
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored.
ignored-argument-names=_.*|^ignored_|^unused_
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io

View file

@ -1,6 +1,6 @@
FROM python:3.13.1-alpine3.20
FROM python:3.9-alpine
ARG STACOSYS_VERSION=3.4
ARG STACOSYS_VERSION=2.1
ARG STACOSYS_FILENAME=stacosys-${STACOSYS_VERSION}-py3-none-any.whl
RUN apk update && apk add bash && apk add wget
@ -17,7 +17,8 @@ COPY docker/docker-init.sh /usr/local/bin/
RUN chmod +x usr/local/bin/docker-init.sh
RUN cd /
COPY dist/${STACOSYS_FILENAME} /
#COPY ${STACOSYS_FILENAME} /
RUN wget https://github.com/kianby/stacosys/releases/download/${STACOSYS_VERSION}/${STACOSYS_FILENAME}
RUN python3 -m pip install ${STACOSYS_FILENAME} --target /stacosys
RUN rm -f ${STACOSYS_FILENAME}

View file

@ -1,46 +0,0 @@
ifeq (run,$(firstword $(MAKECMDGOALS)))
# use the rest as arguments for "run"
RUN_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
# ...and turn them into do-nothing targets
$(eval $(RUN_ARGS):;@:)
endif
.PHONY: all build run test
# code quality
all: black typehint lint
black:
uv run isort --multi-line 3 --profile black src/ tests/
uv run black --target-version py311 src/ tests/
typehint:
uv run mypy --ignore-missing-imports src/ tests/
lint:
uv run pylint src/
# check
check: all
# test
test:
PYTHONPATH=src/ uv run coverage run -m --source=stacosys pytest --junitxml=junit.xml tests
uv run genbadge tests -i junit.xml
uv run coverage xml
uv run genbadge coverage -i coverage.xml
# build
build:
# https://stackoverflow.com/questions/24347450/how-do-you-add-additional-files-to-a-wheel
rm -rf build/* dist/* *.egg-info
uv sync
uv build --wheel --out-dir dist
docker build -t source.madyanne.fr/yax/stacosys .
publish:
docker push source.madyanne.fr/yax/stacosys
# run
run:
PYTHONPATH=src/ uv run python src/stacosys/run.py $(RUN_ARGS)

View file

@ -1,11 +1,13 @@
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![Python version](https://img.shields.io/badge/Python-3.13-blue.svg)](https://www.python.org/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Flask version](https://img.shields.io/badge/Flask-3.1-green.svg)](https://flask.palletsprojects.com)
[![GitLicense](https://gitlicense.com/badge/kianby/stacosys)](https://gitlicense.com/license/kianby/stacosys)
[![Python version](https://img.shields.io/badge/Python-3.9-blue.svg)](https://www.python.org/) [![Flask version](https://img.shields.io/badge/Flask-2.0.1-green.svg)](https://flask.palletsprojects.com) [![Peewee version](https://img.shields.io/badge/Peewee-3.14.0-green.svg)](https://docs.peewee-orm.com/)
[![Build Status - pytest](https://gitea.zaclys.com/yannic/stacosys/raw/branch/main/tests-badge.svg)]() [![Coverage Status](https://gitea.zaclys.com/yannic/stacosys/raw/branch/main/coverage-badge.svg)]()
[![Build Status - pytest](https://github.com/kianby/stacosys/workflows/pytest/badge.svg)](https://github.com/kianby/stacosys) [![Coverage Status](https://coveralls.io/repos/github/kianby/stacosys/badge.svg?branch=main)](https://coveralls.io/github/kianby/stacosys?branch=main) [![Build status - docker image](https://github.com/kianby/stacosys/workflows/docker/badge.svg)](https://hub.docker.com/r/kianby/stacosys)
## Stacosys
Stacosys (aka STAtic blog COmment SYStem) is a fork of Pecosys trying to fix design drawbacks and provide a basic alternative to comment hosting services like Disqus. Stacosys works with any static blog or even a simple HTML page.
Stacosys (aka STAtic blog COmment SYStem) is a fork of [Pecosys](http://github.com/kianby/pecosys) trying to fix Pecosys design drawbacks and to provide an humble alternative to comment hosting services like Disqus. Stacosys protects your readers's privacy.
Stacosys works with any static blog or even a simple HTML page. It uses e-mails to communicate with the blog administrator. It doesn't sound *hype* but I'm an old-school guy. E-mails are reliable and an universal way to communicate. You can answer from any device using an e-mail client.
### Features overview
@ -14,24 +16,25 @@ Stacosys main feature is comment management.
Here is the workflow:
- Readers submit comments via a comment form embedded in blog pages
- Blog administrator receives an e-mail notification from Stacosys when a
- Blog administrator receives an email notification from Stacosys when a
comment is submitted
- Blog administrator can approve or drop the comment through a simple web admin interface
- Blog administrator can approve or drop the comment by replying to e-mail
- Stacosys stores approved comment in its database.
Privacy concerns: only surname, gravatar id and comment itself are stored in DB. E-mail is optionally requested in submission form to resolve gravatar id but never sent to Stacosys.
Privacy concerns: only surname, gravatar id and comment itself are stored in DB. E-mail is requested in submission form (but optional) to resolve gravatar id and it it not sent to stacosys.
Stacosys is more or less localized (english and french).
Stacosys is localized (english and french).
### Technically speaking, how does it work?
Stacosys offers a REST API to retrieve and post comments. Static blog is HTML-based and a piece of JavaScript code interacts with Stacosys using HTTP requests. Each page has a unique id and a request allows retrieving comments for a given page. Similarly, a form request allows to post a comment which is relayed to the administrator by e-mail. For this purpose an SMTP configuration is needed.
Stacosys can be hosted on the same server or on a different server than the blog. Stacosys offers a REST API to retrieve and post comments. Static blog is HTML-based and a piece of JavaScript code interacts with Stacosys using HTTP requests. Each page has a unique id and a simple request allows to retrieve comments for a given page. Similarly a form request allows to post a comment which is relayed to the administrator by e-mail. For this purpose a dedicated email is assigned to Stacosys.
### Little FAQ
*How do you block spammers?*
- Current comment form is basic: no captcha support but protected by a honeypot.
- Current comment form is basic: no captcha support but protected by an honey pot.
*Which database is used?*
@ -41,20 +44,13 @@ Stacosys offers a REST API to retrieve and post comments. Static blog is HTML-ba
- [Python](https://www.python.org)
- [Flask](http://flask.pocoo.org)
- [Peewee ORM](http://docs.peewee-orm.com)
- [Markdown](http://daringfireball.net/projects/markdown)
### Installation
Build and Dependency management relies on [uv](https://docs.astral.sh/uv/)
Run tests and coverage
make test
Build docker image
make build
Build is based on [Poetry](https://python-poetry.org/) but you can also use [published releases](https://github.com/kianby/stacosys/releases) or [Docker image](https://hub.docker.com/r/kianby/stacosys).
### Improvements
Stacosys fits my needs, and it manages comments on [my blog](https://blogduyax.madyanne.fr) for a while. I don't have any plan to make big changes, it's more a python playground for me. So I strongly encourage you to fork and enhance the project if you need additional features.
Stacosys fits my needs and it manages comments on [my blog](https://blogduyax.madyanne.fr) for a while. I don't have any plan to make big changes, it's more a python playground for me. So I strongly encourage you to fork the project and enhance the project if you need more features.

View file

@ -2,12 +2,12 @@
; Default configuration
[main]
lang = fr
db = sqlite://db.sqlite
db_sqlite_file = db.sqlite
newcomment_polling = 60
[site]
name = "My blog"
proto = https
url = https://blog.mydomain.com
url = http://blog.mydomain.com
admin_email = admin@mydomain.com
redirect = /redirect
@ -16,15 +16,21 @@ host = 127.0.0.1
port = 8100
[rss]
proto = https
file = comments.xml
[smtp]
host = smtp.mail.com
port = 465
[imap]
polling = 120
host = mail.gandi.net
ssl = false
port = 993
login = blog@mydomain.com
password = MYPASSWORD
[web]
username = admin
; SHA-256 hashed password (https://coding.tools/sha256)
password = 8C6976E5B5410415BDE908BD4DEE15DFB167A9C873FC4BB8A81F6F2AB448A918
[smtp]
host = mail.gandi.net
starttls = true
ssl = false
port = 587
login = blog@mydomain.com
password = MYPASSWORD

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 86.82%"><title>coverage: 86.82%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="114" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="53" height="20" fill="#97ca00"/><rect width="114" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="865" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">86.82%</text><text x="865" y="140" transform="scale(.1)" fill="#fff" textLength="430">86.82%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,24 +0,0 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
import sqlite3
connection = sqlite3.connect("db.sqlite")
cursor = connection.cursor()
script = """
CREATE TABLE comment (
id INTEGER NOT NULL PRIMARY KEY,
url VARCHAR(255) NOT NULL,
notified DATETIME,
created DATETIME NOT NULL,
published DATETIME,
author_name VARCHAR(255) NOT NULL,
author_site VARCHAR(255) NOT NULL,
author_gravatar varchar(255),
content TEXT NOT NULL
, ulid INTEGER);
"""
cursor.executescript(script)
connection.close()

View file

@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/python
# -*- coding: UTF-8 -*-
import sqlite3
@ -7,9 +7,8 @@ connection = sqlite3.connect("db.sqlite")
cursor = connection.cursor()
# What script performs:
# - first, remove site table: crash here if table doesn't exist
# (compatibility test without effort)
# - remove site_id column from comment table
# - first, remove site table: crash here if table doesn't exist (compatibility test without effort)
# - remove site_id colum from comment table
script = """
PRAGMA foreign_keys = OFF;
BEGIN TRANSACTION;
@ -26,10 +25,8 @@ CREATE TABLE comment (
author_gravatar varchar(255),
content TEXT NOT NULL
);
INSERT INTO comment (id, url, notified, created, published,
author_name, author_site, author_gravatar, content)
SELECT id, url, notified, created, published,
author_name, author_site, author_gravatar, content
INSERT INTO comment (id, url, notified, created, published, author_name, author_site, author_gravatar, content)
SELECT id, url, notified, created, published, author_name, author_site, author_gravatar, content
FROM _comment_old;
DROP TABLE _comment_old;
COMMIT;

View file

@ -1,9 +1,4 @@
#!/bin/bash
cd /stacosys
# workaround for startup
cp -f stacosys/run.py .
python3 run.py /config/config.ini
# catch for debug
#tail -f /dev/null

1044
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,43 +1,32 @@
[project]
[tool.poetry]
name = "stacosys"
version = "3.4"
version = "2.1"
description = "STAtic COmmenting SYStem"
authors = ["Yax"]
readme = "README.md"
authors = [
{ name = "Yax" }
]
requires-python = ">=3.13.1"
dependencies = [
"background>=0.2.1",
"defusedxml>=0.7.1",
"flask>=3.1.0",
"genbadge>=1.1.2",
"markdown>=3.7",
"pydal>=20241204.1",
"pyrss2gen>=1.1",
"requests>=2.32.3",
"types-markdown>=3.7.0.20241204",
]
include = ["run.py"]
[dependency-groups]
dev = [
"coveralls>=4.0.1",
"mypy>=1.13.0",
"pylint>=3.3.2",
"pytest-cov>=6.0.0",
"pytest>=8.3.4",
"black>=24.10.0",
]
[tool.poetry.dependencies]
python = "^3.9"
apscheduler = "^3.6.3"
pyrss2gen = "^1.1"
profig = "^0.5.1"
markdown = "^3.1.1"
flask_apscheduler = "^1.11.0"
Flask = "^2.0.1"
requests = "^2.25.1"
coverage = "^5.5"
peewee = "^3.14.8"
[tool.setuptools]
package-dir = { "" = "src" } # Specify the root directory for packages
packages = ["stacosys", "stacosys.db", "stacosys.i18n", "stacosys.interface", "stacosys.interface.web", "stacosys.interface.templates", "stacosys.model", "stacosys.service"]
[tool.setuptools.package-data]
# Include `.properties` and `.html` files in the specified directories
"stacosys.i18n" = ["*.properties"]
"stacosys.interface.templates" = ["*.html"]
[tool.poetry.dev-dependencies]
rope = "^0.16.0"
mypy = "^0.790"
flake8-black = "^0.2.1"
black = "^20.8b1"
pytest = "^6.2.4"
pytest-cov = "^2.12.1"
coveralls = "^3.2.0"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

124
run.py Normal file
View file

@ -0,0 +1,124 @@
#!/usr/bin/python
# -*- coding: UTF-8 -*-
import sys
import os
import argparse
import logging
import hashlib
from stacosys.conf.config import Config, ConfigParameter
from stacosys.db import database
from stacosys.core.rss import Rss
from stacosys.core.mailer import Mailer
from stacosys.interface import app
from stacosys.interface import api
from stacosys.interface import form
from stacosys.interface import scheduler
# configure logging
def configure_logging(level):
root_logger = logging.getLogger()
root_logger.setLevel(level)
ch = logging.StreamHandler()
ch.setLevel(level)
# create formatter
formatter = logging.Formatter("[%(asctime)s] %(name)s %(levelname)s %(message)s")
# add formatter to ch
ch.setFormatter(formatter)
# add ch to logger
root_logger.addHandler(ch)
def stacosys_server(config_pathname):
# configure logging
logger = logging.getLogger(__name__)
configure_logging(logging.INFO)
logging.getLogger("werkzeug").level = logging.WARNING
logging.getLogger("apscheduler.executors").level = logging.WARNING
# check config file exists
if not os.path.isfile(config_pathname):
logger.error(f"Configuration file '{config_pathname}' not found.")
sys.exit(1)
# initialize config
conf = Config.load(config_pathname)
logger.info(conf.__repr__())
# check database file exists (prevents from creating a fresh db)
db_pathname = conf.get(ConfigParameter.DB_SQLITE_FILE)
if not os.path.isfile(db_pathname):
logger.error(f"Database file '{db_pathname}' not found.")
sys.exit(1)
# initialize database
db = database.Database()
db.setup(db_pathname)
logger.info("Start Stacosys application")
# generate RSS for all sites
rss = Rss(
conf.get(ConfigParameter.LANG),
conf.get(ConfigParameter.RSS_FILE),
conf.get(ConfigParameter.RSS_PROTO),
conf.get(ConfigParameter.SITE_NAME),
conf.get(ConfigParameter.SITE_URL),
)
rss.generate()
# configure mailer
mailer = Mailer(
conf.get(ConfigParameter.IMAP_HOST),
conf.get_int(ConfigParameter.IMAP_PORT),
conf.get_bool(ConfigParameter.IMAP_SSL),
conf.get(ConfigParameter.IMAP_LOGIN),
conf.get(ConfigParameter.IMAP_PASSWORD),
conf.get(ConfigParameter.SMTP_HOST),
conf.get_int(ConfigParameter.SMTP_PORT),
conf.get_bool(ConfigParameter.SMTP_STARTTLS),
conf.get_bool(ConfigParameter.SMTP_SSL),
conf.get(ConfigParameter.SMTP_LOGIN),
conf.get(ConfigParameter.SMTP_PASSWORD),
conf.get(ConfigParameter.SITE_ADMIN_EMAIL)
)
# configure mailer logger
mail_handler = mailer.get_error_handler()
logger.addHandler(mail_handler)
app.logger.addHandler(mail_handler)
# configure scheduler
conf.put(ConfigParameter.SITE_TOKEN, hashlib.sha1(conf.get(ConfigParameter.SITE_NAME).encode('utf-8')).hexdigest())
scheduler.configure(
conf.get_int(ConfigParameter.IMAP_POLLING),
conf.get_int(ConfigParameter.COMMENT_POLLING),
conf.get(ConfigParameter.LANG),
conf.get(ConfigParameter.SITE_NAME),
conf.get(ConfigParameter.SITE_TOKEN),
conf.get(ConfigParameter.SITE_ADMIN_EMAIL),
mailer,
rss,
)
# inject config parameters into flask
app.config.update(SITE_REDIRECT=conf.get(ConfigParameter.SITE_REDIRECT))
logger.info(f"start interfaces {api} {form}")
# start Flask
app.run(
host=conf.get(ConfigParameter.HTTP_HOST),
port=conf.get(ConfigParameter.HTTP_PORT),
debug=False,
use_reloader=False,
)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("config", help="config path name")
args = parser.parse_args()
stacosys_server(args.config)

3
run.sh Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
python3 run.py "$@"

View file

@ -1,29 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from pydal import DAL, Field
class Database:
db_dal = DAL()
def configure(self, db_uri):
self.db_dal = DAL(db_uri, migrate=db_uri.startswith("sqlite:memory"))
self.db_dal.define_table(
"comment",
Field("url"),
Field("created", type="datetime"),
Field("notified", type="datetime"),
Field("published", type="datetime"),
Field("author_name"),
Field("author_site"),
Field("author_gravatar"),
Field("content", type="text"),
)
def get(self):
return self.db_dal
database = Database()
db = database.get

View file

@ -1,80 +0,0 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
# pylint: disable=singleton-comparison
from datetime import datetime
from stacosys.db import db
from stacosys.model.comment import Comment
def find_comment_by_id(comment_id):
return db().comment(comment_id)
def notify_comment(comment: Comment):
db()(db().comment.id == comment.id).update(notified=datetime.now())
db().commit()
def publish_comment(comment: Comment):
db()(db().comment.id == comment.id).update(published=datetime.now())
db().commit()
def delete_comment(comment: Comment):
db()(db().comment.id == comment.id).delete()
db().commit()
def find_not_notified_comments():
return db()(db().comment.notified == None).select()
def find_not_published_comments():
return db()(db().comment.published == None).select()
def find_published_comments_by_url(url):
return db()((db().comment.url == url) & (db().comment.published != None)).select(
orderby=db().comment.published
)
def count_published_comments(url):
return (
db()((db().comment.url == url) & (db().comment.published != None)).count()
if url
else db()(db().comment.published != None).count()
)
def find_recent_published_comments():
return db()(db().comment.published != None).select(
orderby=~db().comment.published, limitby=(0, 10)
)
def create_comment(url, author_name, author_site, author_gravatar, message):
row = db().comment.insert(
url=url,
author_name=author_name,
author_site=author_site,
author_gravatar=author_gravatar,
content=message,
created=datetime.now(),
notified=None,
published=None,
)
db().commit()
return Comment(
id=row.id,
url=row.url,
author_name=row.author_name,
author_site=row.author_site,
author_gravatar=row.author_gravatar,
content=row.content,
created=row.created,
notified=row.notified,
published=row.published,
)

View file

@ -1,23 +0,0 @@
import configparser
import importlib.resources
import os
class Messages:
def __init__(self):
self.property_dict = {}
def load_messages(self, lang):
config = configparser.ConfigParser()
# Access the resource file within the package
with importlib.resources.open_text(
__package__, f"messages_{lang}.properties"
) as file:
config.read_file(file)
for key, value in config.items("messages"):
self.property_dict[key] = value
def get(self, key):
return self.property_dict.get(key)

View file

@ -1,6 +0,0 @@
[messages]
login.failure.username=Username or password incorrect
logout.flash=You have been logged out.
admin.comment.notfound=Comment not found.
admin.comment.approved=Comment published.
admin.comment.deleted=Comment deleted.

View file

@ -1,6 +0,0 @@
[messages]
login.failure.username=Identifiant ou mot de passe incorrect
logout.flash=Vous avez été déconnecté.
admin.comment.notfound=Commentaire introuvable
admin.comment.approved=Commentaire publié
admin.comment.deleted=Commentaire supprimé

View file

@ -1,42 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import logging
import background
from flask import Flask
from stacosys.db import dao
from stacosys.service.configuration import ConfigParameter
app = Flask(__name__)
# Set the secret key to some random bytes. Keep this really secret!
app.secret_key = b'_5#y2L"F4Q8z\n\xec]/'
logger = logging.getLogger(__name__)
@background.task
def submit_new_comment(comment):
site_url = app.config["CONFIG"].get(ConfigParameter.SITE_URL)
comment_list = (
f"Web admin interface: {site_url}/web/admin",
"",
f"author: {comment.author_name}",
f"site: {comment.author_site}",
f"date: {comment.created}",
f"url: {comment.url}",
"",
comment.content,
"",
)
email_body = "\n".join(comment_list)
# send email to notify admin
site_name = app.config["CONFIG"].get(ConfigParameter.SITE_NAME)
subject = f"STACOSYS {site_name}"
if app.config["MAILER"].send(subject, email_body):
logger.debug("new comment processed")
# save notification datetime
dao.notify_comment(comment)

View file

@ -1,64 +0,0 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stacosys Comment Moderation</title>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
</head>
<body>
<header>
<h2>Comment Moderation</h2>
<nav>
<a href="/web/logout">Log out</a>
</nav>
</header>
<main>
{% with messages = get_flashed_messages() %}
{% if messages %}
<blockquote>
{% for message in messages %}
<p>{{ message }}</p>
{% endfor %}
</blockquote>
{% endif %}
{% endwith %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Author</th>
<th>Comment</th>
<th>Article</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for comment in comments %}
<tr>
<td>{{ comment.created }}</td>
<td>{{ comment.author_name }}</td>
<td>{{ comment.content }}</td>
<td><a href="{{ baseurl + comment.url }}">{{ comment.url }}</a></td>
<td>
<form action="/web/admin" method="post">
<input type="hidden" name="comment" value="{{comment.id}}">
<input type="hidden" name="action" value="APPROVE">
<button type="submit">Approve</button>
</form>
<form action="/web/admin" method="post">
<input type="hidden" name="comment" value="{{comment.id}}">
<input type="hidden" name="action" value="REJECT">
<button type="submit">Reject</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</main>
<footer>
<p>This page was designed by Yax with <a href="https://simplecss.org">Simple.css</a>.</p>
</footer>
</body>
</html>

View file

@ -1,64 +0,0 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stacosys</title>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
</head>
<body>
<header>
<h2>Modération des commentaires</h2>
<nav>
<a href="/web/logout">Déconnecter</a>
</nav>
</header>
<main>
{% with messages = get_flashed_messages() %}
{% if messages %}
<blockquote>
{% for message in messages %}
<p>{{ message }}</p>
{% endfor %}
</blockquote>
{% endif %}
{% endwith %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Auteur</th>
<th>Commentaire</th>
<th>Article</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for comment in comments %}
<tr>
<td>{{ comment.created }}</td>
<td>{{ comment.author_name }}</td>
<td>{{ comment.content }}</td>
<td><a href="{{ baseurl + comment.url }}">{{ comment.url }}</a></td>
<td>
<form action="/web/admin" method="post">
<input type="hidden" name="comment" value="{{comment.id}}">
<input type="hidden" name="action" value="APPROVE">
<button type="submit">Accepter</button>
</form>
<form action="/web/admin" method="post">
<input type="hidden" name="comment" value="{{comment.id}}">
<input type="hidden" name="action" value="REJECT">
<button type="submit">Rejeter</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</main>
<footer>
<p>Cette page a été conçue par Yax avec <a href="https://simplecss.org">Simple.css</a>.</p>
</footer>
</body>
</html>

View file

@ -1,42 +0,0 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stacosys</title>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
<style>
form {
width: 350px;
margin: 0 auto;
text-align: center;
}
</style>
</head>
<body>
<header>
<h2>Comment Moderation Login</h2>
</header>
<main>
{% with messages = get_flashed_messages() %}
{% if messages %}
<blockquote>
{% for message in messages %}
<p>{{ message }}</p>
{% endfor %}
</blockquote>
{% endif %}
{% endwith %}
<form action="/web/login" method="POST">
<p><label>Username:</label></p>
<p><input type="text" name="username" /></p>
<p><label>Password:</label></p>
<p><input type="password" name="password" /></p>
<input type="submit" value="Log in" />
</form>
</main>
<footer>
<p>This page was designed with <a href="https://simplecss.org">Simple.css</a>.</p>
</footer>
</body>
</html>

View file

@ -1,42 +0,0 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stacosys</title>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
<style>
form {
width: 350px;
margin: 0 auto;
text-align: center;
}
</style>
</head>
<body>
<header>
<h2>Modération des commentaires</h2>
</header>
<main>
{% with messages = get_flashed_messages() %}
{% if messages %}
<blockquote>
{% for message in messages %}
<p>{{ message }}</p>
{% endfor %}
</blockquote>
{% endif %}
{% endwith %}
<form action="/web/login" method="POST">
<p><label>Utilisateur </label></p>
<p><input type="text" name="username" /></p>
<p><label>Mot de passe </label></p>
<p><input type="password" name="password" /></p>
<input type="submit" value="Connecter" />
</form>
</main>
<footer>
<p>Cette page a été conçue avec <a href="https://simplecss.org">Simple.css</a>.</p>
</footer>
</body>
</html>

View file

@ -1,83 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import hashlib
import logging
from flask import flash, redirect, render_template, request, session
from stacosys.db import dao
from stacosys.interface import app
from stacosys.service.configuration import ConfigParameter
logger = logging.getLogger(__name__)
app.add_url_rule("/web", endpoint="index")
app.add_url_rule("/web/", endpoint="index")
@app.endpoint("index")
def index():
return redirect("/web/admin")
def is_login_ok(username, password):
hashed = hashlib.sha256(password.encode()).hexdigest().upper()
return (
app.config["CONFIG"].get(ConfigParameter.WEB_USERNAME) == username
and app.config["CONFIG"].get(ConfigParameter.WEB_PASSWORD) == hashed
)
@app.route("/web/login", methods=["POST", "GET"])
def login():
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
if is_login_ok(username, password):
session["user"] = username
return redirect("/web/admin")
flash(app.config["MESSAGES"].get("login.failure.username"))
return redirect("/web/login")
# GET
return render_template(
"login_" + app.config["CONFIG"].get(ConfigParameter.LANG) + ".html"
)
@app.route("/web/logout", methods=["GET"])
def logout():
session.pop("user")
flash(app.config["MESSAGES"].get("logout.flash"))
return redirect("/web/admin")
@app.route("/web/admin", methods=["GET"])
def admin_homepage():
if not (
"user" in session
and session["user"] == app.config["CONFIG"].get(ConfigParameter.WEB_USERNAME)
):
return redirect("/web/login")
comments = dao.find_not_published_comments()
return render_template(
"admin_" + app.config["CONFIG"].get(ConfigParameter.LANG) + ".html",
comments=comments,
baseurl=app.config["CONFIG"].get(ConfigParameter.SITE_URL),
)
@app.route("/web/admin", methods=["POST"])
def admin_action():
comment = dao.find_comment_by_id(request.form.get("comment"))
if comment is None:
flash(app.config["MESSAGES"].get("admin.comment.notfound"))
elif request.form.get("action") == "APPROVE":
dao.publish_comment(comment)
app.config["RSS"].generate()
flash(app.config["MESSAGES"].get("admin.comment.approved"))
else:
dao.delete_comment(comment)
flash(app.config["MESSAGES"].get("admin.comment.deleted"))
return redirect("/web/admin")

View file

@ -1,19 +0,0 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class Comment:
id: int = 0
url: str = ""
created: Optional[datetime] = None
notified: Optional[datetime] = None
published: Optional[datetime] = None
author_name: str = ""
author_site: str = ""
author_gravatar: str = ""
content: str = ""

View file

@ -1,105 +0,0 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
import argparse
import logging
import os
import sys
from stacosys.db import database
from stacosys.i18n.messages import Messages
from stacosys.interface import api, app, form
from stacosys.interface.web import admin
from stacosys.service.configuration import Config, ConfigParameter
from stacosys.service.mail import Mailer
from stacosys.service.rssfeed import Rss
# configure logging
def configure_logging() -> logging.Logger:
logging.basicConfig(
level=logging.INFO, format="[%(asctime)s] %(name)s %(levelname)s %(message)s"
)
logger = logging.getLogger(__name__)
logging.getLogger("werkzeug").level = logging.WARNING
return logger
def load_and_validate_config(config_pathname: str, logger: logging.Logger) -> Config:
if not os.path.isfile(config_pathname):
logger.error("Configuration file '%s' not found.", config_pathname)
raise FileNotFoundError(f"Configuration file '{config_pathname}' not found.")
config = Config()
config.load(config_pathname)
if not config.check():
raise ValueError(f"Invalid configuration '{config_pathname}'")
logger.info("Configuration loaded successfully.")
return config
def configure_and_validate_mailer(config, logger):
mailer = Mailer()
mailer.configure_smtp(
config.get(ConfigParameter.SMTP_HOST),
config.get_int(ConfigParameter.SMTP_PORT),
config.get(ConfigParameter.SMTP_LOGIN),
config.get(ConfigParameter.SMTP_PASSWORD),
)
mailer.configure_destination(config.get(ConfigParameter.SITE_ADMIN_EMAIL))
if not mailer.check():
logger.error("Email configuration not working")
sys.exit(1)
return mailer
def configure_rss(config):
rss = Rss()
rss.configure(
config.get(ConfigParameter.RSS_FILE),
config.get(ConfigParameter.SITE_NAME),
config.get(ConfigParameter.SITE_PROTO),
config.get(ConfigParameter.SITE_URL),
)
rss.generate()
return rss
def configure_localization(config):
messages = Messages()
messages.load_messages(config.get(ConfigParameter.LANG))
return messages
def main(config_pathname):
logger = configure_logging()
config = load_and_validate_config(config_pathname, logger)
database.configure(config.get(ConfigParameter.DB))
logger.info("Start Stacosys application")
rss = configure_rss(config)
mailer = configure_and_validate_mailer(config, logger)
messages = configure_localization(config)
logger.info("start interfaces %s %s %s", api, form, admin)
app.config["CONFIG"] = config
app.config["MAILER"] = mailer
app.config["RSS"] = rss
app.config["MESSAGES"] = messages
app.run(
host=config.get(ConfigParameter.HTTP_HOST),
port=config.get_int(ConfigParameter.HTTP_PORT),
debug=False,
use_reloader=False,
)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("config", help="config path name")
args = parser.parse_args()
try:
main(args.config)
except Exception as e:
logging.error("Failed to start application: %s", e)
sys.exit(1)

View file

@ -1,89 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import configparser
from enum import Enum
class ConfigParameter(Enum):
DB = "main.db"
LANG = "main.lang"
HTTP_HOST = "http.host"
HTTP_PORT = "http.port"
RSS_FILE = "rss.file"
SMTP_HOST = "smtp.host"
SMTP_PORT = "smtp.port"
SMTP_LOGIN = "smtp.login"
SMTP_PASSWORD = "smtp.password"
SITE_PROTO = "site.proto"
SITE_NAME = "site.name"
SITE_URL = "site.url"
SITE_ADMIN_EMAIL = "site.admin_email"
SITE_REDIRECT = "site.redirect"
WEB_USERNAME = "web.username"
WEB_PASSWORD = "web.password"
class Config:
_cfg = configparser.ConfigParser()
def load(self, config_pathname):
self._cfg.read(config_pathname)
@staticmethod
def _split_key(key: ConfigParameter):
section, param = str(key.value).split(".")
if not param:
param = section
section = ""
return section, param
def exists(self, key: ConfigParameter):
section, param = self._split_key(key)
return self._cfg.has_option(section, param)
def get(self, key: ConfigParameter) -> str:
section, param = self._split_key(key)
return (
self._cfg.get(section, param)
if self._cfg.has_option(section, param)
else ""
)
def put(self, key: ConfigParameter, value):
section, param = self._split_key(key)
if section and not self._cfg.has_section(section):
self._cfg.add_section(section)
self._cfg.set(section, param, str(value))
def get_int(self, key: ConfigParameter) -> int:
value = self.get(key)
return int(value) if value else 0
def get_bool(self, key: ConfigParameter) -> bool:
value = self.get(key)
assert value in (
"yes",
"true",
"no",
"false",
), f"Parameètre booléen incorrect {key.value}"
return value in ("yes", "true")
def check(self):
for key in ConfigParameter:
if not self.get(key):
return False, key.value
return True, None
def __repr__(self):
dict_repr = {}
for section in self._cfg.sections():
for option in self._cfg.options(section):
dict_repr[".".join([section, option])] = self._cfg.get(section, option)
return str(dict_repr)

View file

@ -1,59 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import logging
from email.mime.text import MIMEText
from smtplib import SMTP_SSL, SMTPAuthenticationError, SMTPException
logger = logging.getLogger(__name__)
class Mailer:
def __init__(self) -> None:
self._smtp_host = ""
self._smtp_port = 0
self._smtp_login = ""
self._smtp_password = ""
self._site_admin_email = ""
def configure_smtp(
self, smtp_host: str, smtp_port: int, smtp_login: str, smtp_password: str
) -> None:
self._smtp_host = smtp_host
self._smtp_port = smtp_port
self._smtp_login = smtp_login
self._smtp_password = smtp_password
def configure_destination(self, site_admin_email: str) -> None:
self._site_admin_email = site_admin_email
def check(self) -> bool:
try:
with SMTP_SSL(self._smtp_host, self._smtp_port) as server:
server.login(self._smtp_login, self._smtp_password)
return True
except SMTPAuthenticationError:
logger.exception("Invalid credentials")
return False
def send(self, subject: str, message: str) -> bool:
sender = self._smtp_login
try:
msg = MIMEText(message)
msg["Subject"] = subject
msg["From"] = sender
msg["To"] = self._site_admin_email
with SMTP_SSL(self._smtp_host, self._smtp_port) as server:
try:
server.login(self._smtp_login, self._smtp_password)
except SMTPAuthenticationError:
logger.exception("Invalid credentials")
return False
server.send_message(msg)
return True
except SMTPException:
logger.error("Error sending email", exc_info=True)
return False

View file

@ -1,56 +0,0 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
from datetime import datetime
import markdown
import PyRSS2Gen
from stacosys.db import dao
class Rss:
def __init__(self) -> None:
self._rss_file: str = ""
self._site_proto: str = ""
self._site_name: str = ""
self._site_url: str = ""
def configure(
self,
rss_file,
site_name,
site_proto,
site_url,
) -> None:
self._rss_file = rss_file
self._site_name = site_name
self._site_proto = site_proto
self._site_url = site_url
def generate(self) -> None:
markdownizer = markdown.Markdown()
items = []
for row in dao.find_recent_published_comments():
item_link = f"{self._site_proto}://{self._site_url}{row.url}"
items.append(
PyRSS2Gen.RSSItem(
title=f"{self._site_proto}://{self._site_url}{row.url} - {row.author_name}",
link=item_link,
description=markdownizer.convert(row.content),
guid=PyRSS2Gen.Guid(f"{item_link}{row.id}"),
pubDate=row.published,
)
)
rss_title = f"Commentaires du site {self._site_name}"
rss = PyRSS2Gen.RSS2(
title=rss_title,
link=f"{self._site_proto}://{self._site_url}",
description=rss_title,
lastBuildDate=datetime.now(),
items=items,
)
with open(self._rss_file, "w", encoding="utf-8") as outfile:
rss.write_xml(outfile, encoding="utf-8")

1
stacosys/__init__.py Normal file
View file

@ -0,0 +1 @@
__version__ = "2.0"

71
stacosys/conf/config.py Normal file
View file

@ -0,0 +1,71 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from enum import Enum
import profig
class ConfigParameter(Enum):
DB_SQLITE_FILE = "main.db_sqlite_file"
LANG = "main.lang"
COMMENT_POLLING = "main.newcomment_polling"
HTTP_HOST = "http.host"
HTTP_PORT = "http.port"
RSS_PROTO = "rss.proto"
RSS_FILE = "rss.file"
IMAP_POLLING = "imap.polling"
IMAP_SSL = "imap.ssl"
IMAP_HOST = "imap.host"
IMAP_PORT = "imap.port"
IMAP_LOGIN = "imap.login"
IMAP_PASSWORD = "imap.password"
SMTP_STARTTLS = "smtp.starttls"
SMTP_SSL = "smtp.ssl"
SMTP_HOST = "smtp.host"
SMTP_PORT = "smtp.port"
SMTP_LOGIN = "smtp.login"
SMTP_PASSWORD = "smtp.password"
SITE_NAME = "site.name"
SITE_URL = "site.url"
SITE_TOKEN = "site.token"
SITE_ADMIN_EMAIL = "site.admin_email"
SITE_REDIRECT = "site.redirect"
class Config:
def __init__(self):
self._params = dict()
@classmethod
def load(cls, config_pathname):
cfg = profig.Config(config_pathname)
cfg.sync()
config = cls()
config._params.update(cfg)
return config
def exists(self, key: ConfigParameter):
return key.value in self._params
def get(self, key: ConfigParameter):
return self._params[key.value] if key.value in self._params else None
def put(self, key: ConfigParameter, value):
self._params[key.value] = value
def get_int(self, key: ConfigParameter):
return int(self._params[key.value])
def get_bool(self, key: ConfigParameter):
value = self._params[key.value].lower()
assert value in ("yes", "true", "no", "false")
return value in ("yes", "true")
def __repr__(self):
return self._params.__repr__()

114
stacosys/core/cron.py Normal file
View file

@ -0,0 +1,114 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import logging
import os
import re
from stacosys.core.mailer import Mailer
from stacosys.core.rss import Rss
from stacosys.core.templater import Templater, Template
from stacosys.db import dao
from stacosys.model.email import Email
REGEX_EMAIL_SUBJECT = r".*STACOSYS.*\[(\d+)\:(\w+)\]"
logger = logging.getLogger(__name__)
current_path = os.path.dirname(__file__)
template_path = os.path.abspath(os.path.join(current_path, "../templates"))
templater = Templater(template_path)
def fetch_mail_answers(lang, mailer: Mailer, rss: Rss, site_token):
while True:
msgs = mailer.fetch()
if len(msgs) == 0:
break
msg = msgs[0]
_process_answer_msg(msg, lang, mailer, rss, site_token)
mailer.delete(msg.id)
def _process_answer_msg(msg, lang, mailer: Mailer, rss: Rss, site_token):
# filter stacosys e-mails
m = re.search(REGEX_EMAIL_SUBJECT, msg.subject, re.DOTALL)
if not m:
return
comment_id = int(m.group(1))
submitted_token = m.group(2)
# validate token
if submitted_token != site_token:
logger.warning("ignore corrupted email. Unknown token %d" % comment_id)
return
if not msg.plain_text_content:
logger.warning("ignore empty email")
return
_reply_comment_email(lang, mailer, rss, msg, comment_id)
def _reply_comment_email(lang, mailer: Mailer, rss: Rss, email: Email, comment_id):
# retrieve comment
comment = dao.find_comment_by_id(comment_id)
if not comment:
logger.warning("unknown comment %d" % comment_id)
return
if comment.published:
logger.warning("ignore already published email. token %d" % comment_id)
return
# safe logic: no answer or unknown answer is a go for publishing
if email.plain_text_content[:2].upper() == "NO":
logger.info("discard comment: %d" % comment_id)
dao.delete_comment(comment)
new_email_body = templater.get_template(lang, Template.DROP_COMMENT).render(
original=email.plain_text_content
)
if not mailer.send(email.from_addr, "Re: " + email.subject, new_email_body):
logger.warning("minor failure. cannot send rejection mail " + email.subject)
else:
# save publishing datetime
dao.publish_comment(comment)
logger.info("commit comment: %d" % comment_id)
# rebuild RSS
rss.generate()
# send approval confirmation email to admin
new_email_body = templater.get_template(lang, Template.APPROVE_COMMENT).render(
original=email.plain_text_content
)
if not mailer.send(email.from_addr, "Re: " + email.subject, new_email_body):
logger.warning("minor failure. cannot send approval email " + email.subject)
def submit_new_comment(lang, site_name, site_token, site_admin_email, mailer):
for comment in dao.find_not_notified_comments():
comment_list = (
"author: %s" % comment.author_name,
"site: %s" % comment.author_site,
"date: %s" % comment.created,
"url: %s" % comment.url,
"",
"%s" % comment.content,
"",
)
comment_text = "\n".join(comment_list)
email_body = templater.get_template(lang, Template.NEW_COMMENT).render(
url=comment.url, comment=comment_text
)
# send email to notify admin
subject = "STACOSYS %s: [%d:%s]" % (site_name, comment.id, site_token)
if mailer.send(site_admin_email, subject, email_body):
logger.debug("new comment processed ")
# save notification datetime
dao.notify_comment(comment)
else:
logger.warning("rescheduled. send mail failure " + subject)

161
stacosys/core/imap.py Executable file
View file

@ -0,0 +1,161 @@
#!/usr/bin/env python
# -*- coding:utf-8 -*-
import base64
import datetime
import email
import imaplib
import logging
import re
from email.message import Message
from stacosys.model.email import Attachment, Email, Part
filename_re = re.compile('filename="(.+)"|filename=([^;\n\r"\']+)', re.I | re.S)
class Mailbox(object):
def __init__(self, host, port, ssl, login, password):
self.logger = logging.getLogger(__name__)
self.host = host
self.port = port
self.ssl = ssl
self.login = login
self.password = password
def __enter__(self):
if self.ssl:
self.imap = imaplib.IMAP4_SSL(self.host, self.port)
else:
self.imap = imaplib.IMAP4(self.host, self.port)
self.imap.login(self.login, self.password)
return self
def __exit__(self, _type, value, traceback):
self.imap.close()
self.imap.logout()
def get_count(self):
self.imap.select("Inbox")
_, data = self.imap.search(None, "ALL")
return sum(1 for _ in data[0].split())
def fetch_raw_message(self, num):
self.imap.select("Inbox")
_, data = self.imap.fetch(str(num), "(RFC822)")
email_msg = email.message_from_bytes(data[0][1])
return email_msg
def fetch_message(self, num):
raw_msg = self.fetch_raw_message(num)
parts = []
attachments = []
plain_text_content = "no plain-text part"
for part in raw_msg.walk():
if part.is_multipart():
continue
if _is_part_attachment(part):
attachments.append(_get_attachment(part))
else:
try:
content = _to_plain_text_content(part)
parts.append(
Part(content=content, content_type=part.get_content_type())
)
if part.get_content_type() == "text/plain":
plain_text_content = content
except Exception:
logging.exception("cannot extract content from mail part")
return Email(
id=num,
encoding="UTF-8",
date=_parse_date(raw_msg["Date"]).strftime("%Y-%m-%d %H:%M:%S"),
from_addr=raw_msg["From"],
to_addr=raw_msg["To"],
subject=_email_non_ascii_to_uft8(raw_msg["Subject"]),
parts=parts,
attachments=attachments,
plain_text_content=plain_text_content,
)
def delete_message(self, num):
self.imap.select("Inbox")
self.imap.store(str(num), "+FLAGS", r"\Deleted")
self.imap.expunge()
def delete_all(self):
self.imap.select("Inbox")
_, data = self.imap.search(None, "ALL")
for num in data[0].split():
self.imap.store(num, "+FLAGS", r"\Deleted")
self.imap.expunge()
def print_msgs(self):
self.imap.select("Inbox")
_, data = self.imap.search(None, "ALL")
for num in reversed(data[0].split()):
status, data = self.imap.fetch(num, "(RFC822)")
self.logger.debug("Message %s\n%s\n" % (num, data[0][1]))
def _parse_date(v):
if v is None:
return datetime.datetime.now()
tt = email.utils.parsedate_tz(v)
if tt is None:
return datetime.datetime.now()
timestamp = email.utils.mktime_tz(tt)
date = datetime.datetime.fromtimestamp(timestamp)
return date
def _to_utf8(string, charset):
return string.decode(charset).encode("UTF-8").decode("UTF-8")
def _email_non_ascii_to_uft8(string):
# RFC 1342 is a recommendation that provides a way to represent non ASCII
# characters inside e-mail in a way that wont confuse e-mail servers
subject = ""
for v, charset in email.header.decode_header(string):
if charset is None or charset == 'unknown-8bit':
if type(v) is bytes:
v = v.decode()
subject = subject + v
else:
subject = subject + _to_utf8(v, charset)
return subject
def _to_plain_text_content(part: Message) -> str:
content = part.get_payload(decode=True)
charset = part.get_param("charset", None)
if charset:
content = _to_utf8(content, charset)
elif type(content) == bytes:
content = content.decode("utf8")
# RFC 3676: remove automatic word-wrapping
return content.replace(" \r\n", " ")
def _is_part_attachment(part):
return part.get("Content-Disposition", None)
def _get_attachment(part) -> Attachment:
content_disposition = part.get("Content-Disposition", None)
r = filename_re.findall(content_disposition)
if r:
filename = sorted(r[0])[1]
else:
filename = "undefined"
content = base64.b64encode(part.get_payload(decode=True))
content = content.decode()
return Attachment(
filename=_email_non_ascii_to_uft8(filename),
content=content,
content_type=part.get_content_type(),
)

150
stacosys/core/mailer.py Normal file
View file

@ -0,0 +1,150 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import logging
import smtplib
import email.utils
from email.mime.text import MIMEText
from email.message import EmailMessage
from logging.handlers import SMTPHandler
from stacosys.core import imap
logger = logging.getLogger(__name__)
class Mailer:
def __init__(
self,
imap_host,
imap_port,
imap_ssl,
imap_login,
imap_password,
smtp_host,
smtp_port,
smtp_starttls,
smtp_ssl,
smtp_login,
smtp_password,
site_admin_email,
):
self._imap_host = imap_host
self._imap_port = imap_port
self._imap_ssl = imap_ssl
self._imap_login = imap_login
self._imap_password = imap_password
self._smtp_host = smtp_host
self._smtp_port = smtp_port
self._smtp_starttls = smtp_starttls
self._smtp_ssl = smtp_ssl
self._smtp_login = smtp_login
self._smtp_password = smtp_password
self._site_admin_email = site_admin_email
def _open_mailbox(self):
return imap.Mailbox(
self._imap_host,
self._imap_port,
self._imap_ssl,
self._imap_login,
self._imap_password,
)
def fetch(self):
msgs = []
try:
with self._open_mailbox() as mbox:
count = mbox.get_count()
for num in range(count):
msgs.append(mbox.fetch_message(num + 1))
except Exception:
logger.exception("fetch mail exception")
return msgs
def send(self, to_email, subject, message):
# Create the container (outer) email message.
msg = MIMEText(message)
msg["Subject"] = subject
msg["To"] = to_email
msg["From"] = self._smtp_login
success = True
try:
if self._smtp_ssl:
s = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port)
else:
s = smtplib.SMTP(self._smtp_host, self._smtp_port)
if self._smtp_starttls:
s.starttls()
if self._smtp_login:
s.login(self._smtp_login, self._smtp_password)
s.send_message(msg)
s.quit()
except Exception:
logger.exception("send mail exception")
success = False
return success
def delete(self, id):
try:
with self._open_mailbox() as mbox:
mbox.delete_message(id)
except Exception:
logger.exception("delete mail exception")
def get_error_handler(self):
if self._smtp_ssl:
mail_handler = SSLSMTPHandler(
mailhost=(
self._smtp_host,
self._smtp_port,
),
credentials=(
self._smtp_login,
self._smtp_password,
),
fromaddr=self._smtp_login,
toaddrs=self._site_admin_email,
subject="Stacosys error",
)
else:
mail_handler = SMTPHandler(
mailhost=(
self._smtp_host,
self._smtp_port,
),
credentials=(
self._smtp_login,
self._smtp_password,
),
fromaddr=self._smtp_login,
toaddrs=self._site_admin_email,
subject="Stacosys error",
)
mail_handler.setLevel(logging.ERROR)
return mail_handler
class SSLSMTPHandler(SMTPHandler):
def emit(self, record):
"""
Emit a record.
Format the record and send it to the specified addressees.
"""
try:
smtp = smtplib.SMTP_SSL(self.mailhost, self.mailport)
msg = EmailMessage()
msg["From"] = self.fromaddr
msg["To"] = ",".join(self.toaddrs)
msg["Subject"] = self.getSubject(record)
msg["Date"] = email.utils.localtime()
msg.set_content(self.format(record))
if self.username:
smtp.login(self.username, self.password)
smtp.send_message(msg)
smtp.quit()
except Exception:
self.handleError(record)

64
stacosys/core/rss.py Normal file
View file

@ -0,0 +1,64 @@
#!/usr/bin/python
# -*- coding: UTF-8 -*-
import os
from datetime import datetime
import markdown
import PyRSS2Gen
from stacosys.core.templater import Templater, Template
from stacosys.model.comment import Comment
class Rss:
def __init__(
self,
lang,
rss_file,
rss_proto,
site_name,
site_url,
):
self._lang = lang
self._rss_file = rss_file
self._rss_proto = rss_proto
self._site_name = site_name
self._site_url = site_url
current_path = os.path.dirname(__file__)
template_path = os.path.abspath(os.path.join(current_path, "../templates"))
self._templater = Templater(template_path)
def generate(self):
rss_title = self._templater.get_template(
self._lang, Template.RSS_TITLE_MESSAGE
).render(site=self._site_name)
md = markdown.Markdown()
items = []
for row in (
Comment.select()
.where(Comment.published)
.order_by(-Comment.published)
.limit(10)
):
item_link = "%s://%s%s" % (self._rss_proto, self._site_url, row.url)
items.append(
PyRSS2Gen.RSSItem(
title="%s - %s://%s%s"
% (self._rss_proto, row.author_name, self._site_url, row.url),
link=item_link,
description=md.convert(row.content),
guid=PyRSS2Gen.Guid("%s/%d" % (item_link, row.id)),
pubDate=row.published,
)
)
rss = PyRSS2Gen.RSS2(
title=rss_title,
link="%s://%s" % (self._rss_proto, self._site_url),
description='Commentaires du site "%s"' % self._site_name,
lastBuildDate=datetime.now(),
items=items,
)
rss.write_xml(open(self._rss_file, "w"), encoding="utf-8")

View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from enum import Enum
from jinja2 import Environment, FileSystemLoader
class Template(Enum):
DROP_COMMENT = "drop_comment"
APPROVE_COMMENT = "approve_comment"
NEW_COMMENT = "new_comment"
NOTIFY_MESSAGE = "notify_message"
RSS_TITLE_MESSAGE = "rss_title_message"
class Templater:
def __init__(self, template_path):
self._env = Environment(loader=FileSystemLoader(template_path))
def get_template(self, lang, template: Template):
return self._env.get_template(lang + "/" + template.value + ".tpl")

0
stacosys/db/__init__.py Normal file
View file

56
stacosys/db/dao.py Normal file
View file

@ -0,0 +1,56 @@
#!/usr/bin/python
# -*- coding: UTF-8 -*-
from datetime import datetime
from stacosys.model.comment import Comment
TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
def find_comment_by_id(id):
return Comment.get_by_id(id)
def notify_comment(comment: Comment):
comment.notified = datetime.now().strftime(TIME_FORMAT)
comment.save()
def publish_comment(comment: Comment):
comment.published = datetime.now().strftime(TIME_FORMAT)
comment.save()
def delete_comment(comment: Comment):
comment.delete_instance()
def find_not_notified_comments():
return Comment.select().where(Comment.notified.is_null())
def find_published_comments_by_url(url):
return Comment.select(Comment).where((Comment.url == url) & (Comment.published.is_null(False))).order_by(
+Comment.published)
def count_published_comments(url):
return Comment.select(Comment).where(
(Comment.url == url) & (Comment.published.is_null(False))).count() if url else Comment.select(Comment).where(
Comment.published.is_null(False)).count()
def create_comment(url, author_name, author_site, author_gravatar, message):
created = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
comment = Comment(
url=url,
author_name=author_name,
author_site=author_site,
author_gravatar=author_gravatar,
content=message,
created=created,
notified=None,
published=None,
)
comment.save()
return comment

24
stacosys/db/database.py Normal file
View file

@ -0,0 +1,24 @@
#!/usr/bin/python
# -*- coding: UTF-8 -*-
from peewee import Model
from playhouse.db_url import SqliteDatabase
db = SqliteDatabase(None)
class BaseModel(Model):
class Meta:
database = db
class Database:
def get_db(self):
return db
def setup(self, db_url):
db.init(db_url)
db.connect()
from stacosys.model.comment import Comment
db.create_tables([Comment], safe=True)

View file

@ -0,0 +1,6 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from flask import Flask
app = Flask(__name__)

View file

@ -6,40 +6,37 @@ import logging
from flask import jsonify, request
from stacosys.db import dao
from stacosys.interface import app, submit_new_comment
from stacosys.interface import app
logger = logging.getLogger(__name__)
@app.route("/api/ping", methods=["GET"])
@app.route("/ping", methods=["GET"])
def ping():
return "OK"
@app.route("/api/comments", methods=["GET"])
@app.route("/comments", methods=["GET"])
def query_comments():
comments = []
url = request.args.get("url", "")
logger.info("retrieve comments for url %s", url)
logger.info("retrieve comments for url %s" % url)
for comment in dao.find_published_comments_by_url(url):
comment_dto = {
d = {
"author": comment.author_name,
"content": comment.content,
"avatar": comment.author_gravatar,
"date": comment.published.strftime("%Y-%m-%d %H:%M:%S"),
}
if comment.author_site:
comment_dto["site"] = comment.author_site
logger.debug(comment_dto)
comments.append(comment_dto)
d["site"] = comment.author_site
logger.debug(d)
comments.append(d)
return jsonify({"data": comments})
@app.route("/api/comments/count", methods=["GET"])
@app.route("/comments/count", methods=["GET"])
def get_comments_count():
# send notification for pending e-mails asynchronously
for comment in dao.find_not_notified_comments():
submit_new_comment(comment)
url = request.args.get("url", "")
return jsonify({"count": dao.count_published_comments(url)})

View file

@ -1,25 +1,26 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import logging
from flask import abort, redirect, request
from stacosys.db import dao
from stacosys.interface import app, submit_new_comment
from stacosys.service.configuration import ConfigParameter
from stacosys.interface import app
logger = logging.getLogger(__name__)
@app.route("/newcomment", methods=["POST"])
def new_form_comment():
data = request.form
logger.info("form data %s", str(data))
logger.info("form data " + str(data))
# honeypot for spammers
captcha = data.get("remarque", "")
if captcha:
logger.warning("discard spam: data %s", data)
logger.warning("discard spam: data %s" % data)
abort(400)
url = data.get("url", "")
@ -32,24 +33,21 @@ def new_form_comment():
# anti-spam again
if not url or not author_name or not message:
logger.warning("empty field: data %s", data)
logger.warning("empty field: data %s" % data)
abort(400)
if not check_form_data(data.to_dict()):
logger.warning("additional field: data %s", data)
logger.warning("additional field: data %s" % data)
abort(400)
# add a row to Comment table
comment = dao.create_comment(
url, author_name, author_site, author_gravatar, message
)
dao.create_comment(url, author_name, author_site, author_gravatar, message)
# send notification e-mail asynchronously
submit_new_comment(comment)
return redirect(app.config["CONFIG"].get(ConfigParameter.SITE_REDIRECT), code=302)
return redirect(app.config.get("SITE_REDIRECT"), code=302)
def check_form_data(posted_comment):
def check_form_data(d):
fields = ["url", "message", "site", "remarque", "author", "token", "email"]
filtered = dict(filter(lambda x: x[0] not in fields, posted_comment.items()))
filtered = dict(filter(lambda x: x[0] not in fields, d.items()))
return not filtered

View file

@ -0,0 +1,67 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from flask_apscheduler import APScheduler
from stacosys.interface import app
class JobConfig(object):
JOBS: list = []
SCHEDULER_EXECUTORS = {"default": {"type": "threadpool", "max_workers": 4}}
def __init__(
self,
imap_polling_seconds,
new_comment_polling_seconds,
lang,
site_name,
site_token,
site_admin_email,
mailer,
rss,
):
self.JOBS = [
{
"id": "fetch_mail",
"func": "stacosys.core.cron:fetch_mail_answers",
"args": [lang, mailer, rss, site_token],
"trigger": "interval",
"seconds": imap_polling_seconds,
},
{
"id": "submit_new_comment",
"func": "stacosys.core.cron:submit_new_comment",
"args": [lang, site_name, site_token, site_admin_email, mailer],
"trigger": "interval",
"seconds": new_comment_polling_seconds,
},
]
def configure(
imap_polling,
comment_polling,
lang,
site_name,
site_token,
site_admin_email,
mailer,
rss,
):
app.config.from_object(
JobConfig(
imap_polling,
comment_polling,
lang,
site_name,
site_token,
site_admin_email,
mailer,
rss,
)
)
scheduler = APScheduler()
scheduler.init_app(app)
scheduler.start()

19
stacosys/model/comment.py Normal file
View file

@ -0,0 +1,19 @@
#!/usr/bin/python
# -*- coding: UTF-8 -*-
from peewee import CharField
from peewee import DateTimeField
from peewee import TextField
from stacosys.db.database import BaseModel
class Comment(BaseModel):
url = CharField()
created = DateTimeField()
notified = DateTimeField(null=True, default=None)
published = DateTimeField(null=True, default=None)
author_name = CharField()
author_site = CharField(default="")
author_gravatar = CharField(default="")
content = TextField()

32
stacosys/model/email.py Normal file
View file

@ -0,0 +1,32 @@
#!/usr/bin/python
# -*- coding: UTF-8 -*-
from dataclasses import dataclass
from datetime import datetime
from typing import List
@dataclass
class Part:
content: str
content_type: str
@dataclass
class Attachment:
filename: str
content: str
content_type: str
@dataclass
class Email:
id: int
encoding: str
date: datetime
from_addr: str
to_addr: str
subject: str
parts: List[Part]
attachments: List[Attachment]
plain_text_content: str

View file

@ -0,0 +1,9 @@
Hi,
The comment should be published soon. It has been approved.
--
Stacosys
{{ original }}

View file

@ -0,0 +1,9 @@
Hi,
The comment will not be published. It has been dropped.
--
Stacosys
{{ original }}

View file

@ -0,0 +1,16 @@
Hi,
A new comment has been submitted for post {{ url }}
You have two choices:
- reject the comment by replying NO (or no),
- accept the comment by sending back the email as it is.
If you choose the latter option, Stacosys is going to publish the commennt.
Please find comment details below:
{{ comment }}
--
Stacosys

View file

@ -0,0 +1 @@
New comment

View file

@ -0,0 +1 @@
{{ site }} : comments

View file

@ -0,0 +1,9 @@
Bonjour,
Le commentaire sera bientôt publié. Il a été approuvé.
--
Stacosys
{{ original }}

View file

@ -0,0 +1,9 @@
Bonjour,
Le commentaire ne sera pas publié. Il a été rejeté.
--
Stacosys
{{ original }}

View file

@ -0,0 +1,16 @@
Bonjour,
Un nouveau commentaire a été posté pour l'article {{ url }}
Vous avez deux réponses possibles :
- rejeter le commentaire en répondant NO (ou no),
- accepter le commentaire en renvoyant cet email tel quel.
Si cette dernière option est choisie, Stacosys publiera le commentaire très bientôt.
Voici les détails concernant le commentaire :
{{ comment }}
--
Stacosys

View file

@ -0,0 +1 @@
Nouveau commentaire

View file

@ -0,0 +1 @@
{{ site }} : commentaires

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="20" role="img" aria-label="tests: 19"><title>tests: 19</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="60" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="37" height="20" fill="#555"/><rect x="37" width="23" height="20" fill="#4c1"/><rect width="60" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="195" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">tests</text><text x="195" y="140" transform="scale(.1)" fill="#fff" textLength="270">tests</text><text aria-hidden="true" x="475" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="130">19</text><text x="475" y="140" transform="scale(.1)" fill="#fff" textLength="130">19</text></g></svg>

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/python
# -*- coding: UTF-8 -*-
import json
@ -6,8 +6,9 @@ import logging
import pytest
from stacosys.db import dao, database
from stacosys.interface import api, app
from stacosys.db import database, dao
from stacosys.interface import api
from stacosys.interface import app
def init_test_db():
@ -16,48 +17,49 @@ def init_test_db():
c3 = dao.create_comment("/site3", "Jack", "/jack.site", "", "comment 3")
dao.publish_comment(c1)
dao.publish_comment(c3)
assert c2
@pytest.fixture
def client():
logger = logging.getLogger(__name__)
database.configure("sqlite:memory://db.sqlite")
db = database.Database()
db.setup(":memory:")
init_test_db()
app.config.update(SITE_TOKEN="ETC")
logger.info(f"start interface {api}")
return app.test_client()
def test_api_ping(client):
resp = client.get("/api/ping")
resp = client.get('/ping')
assert resp.data == b"OK"
def test_api_count_global(client):
resp = client.get("/api/comments/count")
resp = client.get('/comments/count')
d = json.loads(resp.data)
assert d and d["count"] == 2
assert d and d['count'] == 2
def test_api_count_url(client):
resp = client.get("/api/comments/count?url=/site1")
resp = client.get('/comments/count?url=/site1')
d = json.loads(resp.data)
assert d and d["count"] == 1
resp = client.get("/api/comments/count?url=/site2")
assert d and d['count'] == 1
resp = client.get('/comments/count?url=/site2')
d = json.loads(resp.data)
assert d and d["count"] == 0
assert d and d['count'] == 0
def test_api_comment(client):
resp = client.get("/api/comments?url=/site1")
resp = client.get('/comments?url=/site1')
d = json.loads(resp.data)
assert d and len(d["data"]) == 1
comment = d["data"][0]
assert comment["author"] == "Bob"
assert comment["content"] == "comment 1"
assert d and len(d['data']) == 1
comment = d['data'][0]
assert comment['author'] == 'Bob'
assert comment['content'] == 'comment 1'
def test_api_comment_not_found(client):
resp = client.get("/api/comments?url=/site2")
resp = client.get('/comments?url=/site2')
d = json.loads(resp.data)
assert d and d["data"] == []
assert d and d['data'] == []

View file

@ -1,48 +1,48 @@
#!/usr/bin/env python
#!/usr/bin/python
# -*- coding: UTF-8 -*-
import pytest
import unittest
from stacosys.service.configuration import Config, ConfigParameter
from stacosys.conf.config import Config, ConfigParameter
EXPECTED_DB = "sqlite://db.sqlite"
EXPECTED_DB_SQLITE_FILE = "db.sqlite"
EXPECTED_HTTP_PORT = 8080
EXPECTED_LANG = "fr"
config = Config()
EXPECTED_IMAP_PORT = "5000"
EXPECTED_IMAP_LOGIN = "user"
@pytest.fixture
def init_config():
config.put(ConfigParameter.DB, EXPECTED_DB)
config.put(ConfigParameter.HTTP_PORT, EXPECTED_HTTP_PORT)
class ConfigTestCase(unittest.TestCase):
def setUp(self):
self.conf = Config()
self.conf.put(ConfigParameter.DB_SQLITE_FILE, EXPECTED_DB_SQLITE_FILE)
self.conf.put(ConfigParameter.HTTP_PORT, EXPECTED_HTTP_PORT)
self.conf.put(ConfigParameter.IMAP_PORT, EXPECTED_IMAP_PORT)
self.conf.put(ConfigParameter.SMTP_STARTTLS, "yes")
self.conf.put(ConfigParameter.IMAP_SSL, "false")
def test_split_key():
section, param = config._split_key(ConfigParameter.HTTP_PORT)
assert section == "http" and param == "port"
def test_exists(self):
self.assertTrue(self.conf.exists(ConfigParameter.DB_SQLITE_FILE))
self.assertFalse(self.conf.exists(ConfigParameter.IMAP_HOST))
def test_get(self):
self.assertEqual(self.conf.get(ConfigParameter.DB_SQLITE_FILE), EXPECTED_DB_SQLITE_FILE)
self.assertEqual(self.conf.get(ConfigParameter.HTTP_PORT), EXPECTED_HTTP_PORT)
self.assertIsNone(self.conf.get(ConfigParameter.HTTP_HOST))
self.assertEqual(self.conf.get(ConfigParameter.HTTP_PORT), EXPECTED_HTTP_PORT)
self.assertEqual(self.conf.get(ConfigParameter.IMAP_PORT), EXPECTED_IMAP_PORT)
self.assertEqual(self.conf.get_int(ConfigParameter.IMAP_PORT), int(EXPECTED_IMAP_PORT))
self.assertEqual(self.conf.get_int(ConfigParameter.HTTP_PORT), 8080)
self.assertTrue(self.conf.get_bool(ConfigParameter.SMTP_STARTTLS))
self.assertFalse(self.conf.get_bool(ConfigParameter.IMAP_SSL))
try:
self.conf.get_bool(ConfigParameter.DB_SQLITE_FILE)
self.assertTrue(False)
except AssertionError:
pass
def test_exists(init_config):
assert config.exists(ConfigParameter.DB)
def test_get(init_config):
assert config.get(ConfigParameter.DB) == EXPECTED_DB
assert config.get(ConfigParameter.HTTP_HOST) == ""
assert config.get(ConfigParameter.HTTP_PORT) == str(EXPECTED_HTTP_PORT)
assert config.get_int(ConfigParameter.HTTP_PORT) == EXPECTED_HTTP_PORT
with pytest.raises(AssertionError):
config.get_bool(ConfigParameter.DB)
def test_put(init_config):
assert not config.exists(ConfigParameter.LANG)
config.put(ConfigParameter.LANG, EXPECTED_LANG)
assert config.exists(ConfigParameter.LANG)
assert config.get(ConfigParameter.LANG) == EXPECTED_LANG
def test_check(init_config):
success, error = config.check()
assert not success and error
def test_put(self):
self.assertFalse(self.conf.exists(ConfigParameter.IMAP_LOGIN))
self.conf.put(ConfigParameter.IMAP_LOGIN, EXPECTED_IMAP_LOGIN)
self.assertTrue(self.conf.exists(ConfigParameter.IMAP_LOGIN))
self.assertEqual(self.conf.get(ConfigParameter.IMAP_LOGIN), EXPECTED_IMAP_LOGIN)

View file

@ -1,124 +1,55 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
import unittest
import time
import pytest
from stacosys.db import dao, database
from stacosys.model.comment import Comment
from stacosys.db import dao
from stacosys.db import database
@pytest.fixture
def setup_db():
database.configure("sqlite:memory://db.sqlite")
class DbTestCase(unittest.TestCase):
def setUp(self):
db = database.Database()
db.setup(":memory:")
def equals_comment(comment: Comment, other):
return (
comment.id == other.id
and comment.author_gravatar == other.author_gravatar
and comment.author_name == other.author_name
and comment.author_site == other.author_site
and comment.content == other.content
and comment.created == other.created
and comment.notified == other.notified
and comment.published == other.published
)
def test_dao_published(self):
def test_find_comment_by_id(setup_db):
assert dao.find_comment_by_id(1) is None
# test count published
self.assertEqual(0, dao.count_published_comments(""))
c1 = dao.create_comment("/post1", "Yax", "", "", "Comment 1")
assert c1.id is not None
find_c1 = dao.find_comment_by_id(c1.id)
assert find_c1
assert equals_comment(c1, find_c1)
c1.id = find_c1.id
dao.delete_comment(c1)
assert dao.find_comment_by_id(c1.id) is None
def test_dao_published(setup_db):
assert 0 == dao.count_published_comments("")
c1 = dao.create_comment("/post1", "Yax", "", "", "Comment 1")
assert 0 == dao.count_published_comments("")
assert 1 == len(dao.find_not_published_comments())
self.assertEqual(0, dao.count_published_comments(""))
dao.publish_comment(c1)
assert 1 == dao.count_published_comments("")
self.assertEqual(1, dao.count_published_comments(""))
c2 = dao.create_comment("/post2", "Yax", "", "", "Comment 2")
dao.publish_comment(c2)
assert 2 == dao.count_published_comments("")
self.assertEqual(2, dao.count_published_comments(""))
c3 = dao.create_comment("/post2", "Yax", "", "", "Comment 3")
dao.publish_comment(c3)
assert 0 == len(dao.find_not_published_comments())
self.assertEqual(1, dao.count_published_comments("/post1"))
self.assertEqual(2, dao.count_published_comments("/post2"))
# count published
assert 1 == dao.count_published_comments("/post1")
assert 2 == dao.count_published_comments("/post2")
# test find published
self.assertEqual(0, len(dao.find_published_comments_by_url("/")))
self.assertEqual(1, len(dao.find_published_comments_by_url("/post1")))
self.assertEqual(2, len(dao.find_published_comments_by_url("/post2")))
# find published
assert 0 == len(dao.find_published_comments_by_url("/"))
assert 1 == len(dao.find_published_comments_by_url("/post1"))
assert 2 == len(dao.find_published_comments_by_url("/post2"))
dao.delete_comment(c1)
self.assertEqual(0, len(dao.find_published_comments_by_url("/post1")))
def test_dao_notified(self):
def test_dao_notified(setup_db):
assert 0 == len(dao.find_not_notified_comments())
# test count notified
self.assertEqual(0, len(dao.find_not_notified_comments()))
c1 = dao.create_comment("/post1", "Yax", "", "", "Comment 1")
assert 1 == len(dao.find_not_notified_comments())
self.assertEqual(1, len(dao.find_not_notified_comments()))
c2 = dao.create_comment("/post2", "Yax", "", "", "Comment 2")
assert 2 == len(dao.find_not_notified_comments())
self.assertEqual(2, len(dao.find_not_notified_comments()))
dao.notify_comment(c1)
dao.notify_comment(c2)
assert 0 == len(dao.find_not_notified_comments())
self.assertEqual(0, len(dao.find_not_notified_comments()))
c3 = dao.create_comment("/post2", "Yax", "", "", "Comment 3")
assert 1 == len(dao.find_not_notified_comments())
self.assertEqual(1, len(dao.find_not_notified_comments()))
dao.notify_comment(c3)
assert 0 == len(dao.find_not_notified_comments())
self.assertEqual(0, len(dao.find_not_notified_comments()))
def create_comment(url, author_name, content):
return dao.create_comment(url, author_name, "", "", content)
def test_find_recent_published_comments(setup_db):
comments = [
create_comment("/post", "Adam", "Comment 1"),
create_comment("/post", "Arf", "Comment 2"),
create_comment("/post", "Arwin", "Comment 3"),
create_comment("/post", "Bill", "Comment 4"),
create_comment("/post", "Bo", "Comment 5"),
create_comment("/post", "Charles", "Comment 6"),
create_comment("/post", "Dan", "Comment 7"),
create_comment("/post", "Dwayne", "Comment 8"),
create_comment("/post", "Erl", "Comment 9"),
create_comment("/post", "Jay", "Comment 10"),
create_comment("/post", "Kenny", "Comment 11"),
create_comment("/post", "Lord", "Comment 12"),
]
rows = dao.find_recent_published_comments()
assert len(rows) == 0
# publish every second
for comment in comments:
dao.publish_comment(comment)
time.sleep(1)
rows = dao.find_recent_published_comments()
assert len(rows) == 10
authors = [row.author_name for row in rows]
assert authors == [
"Lord",
"Kenny",
"Jay",
"Erl",
"Dwayne",
"Dan",
"Charles",
"Bo",
"Bill",
"Arwin",
]
if __name__ == '__main__':
unittest.main()

View file

@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/python
# -*- coding: UTF-8 -*-
import logging
@ -6,43 +6,35 @@ import logging
import pytest
from stacosys.db import database
from stacosys.interface import app, form
from stacosys.service.configuration import Config
from stacosys.service.mail import Mailer
from stacosys.service.rssfeed import Rss
from stacosys.interface import app
from stacosys.interface import form
@pytest.fixture
def client():
logger = logging.getLogger(__name__)
database.configure("sqlite:memory://db.sqlite")
db = database.Database()
db.setup(":memory:")
app.config.update(SITE_REDIRECT="/redirect")
logger.info(f"start interface {form}")
app.config["CONFIG"] = Config()
app.config["MAILER"] = Mailer()
app.config["RSS"] = Rss()
return app.test_client()
def test_new_comment_honeypot(client):
resp = client.post(
"/newcomment", content_type="multipart/form-data", data={"remarque": "trapped"}
)
assert resp.status == "400 BAD REQUEST"
resp = client.post('/newcomment',
content_type='multipart/form-data',
data={'remarque': 'trapped'})
assert resp.status == '400 BAD REQUEST'
def test_new_comment_success(client):
resp = client.post(
"/newcomment",
content_type="multipart/form-data",
data={"author": "Jack", "url": "/site3", "message": "comment 3"},
)
assert resp.status == "302 FOUND"
resp = client.post('/newcomment',
content_type='multipart/form-data',
data={'author': 'Jack', 'url': '/site3', 'message': 'comment 3'})
assert resp.status == '302 FOUND'
def test_check_form_data():
from stacosys.interface.form import check_form_data
assert check_form_data({"author": "Jack", "url": "/site3", "message": "comment 3"})
assert not check_form_data(
{"author": "Jack", "url": "/site3", "message": "comment 3", "extra": "ball"}
)
assert check_form_data({'author': 'Jack', 'url': '/site3', 'message': 'comment 3'})
assert not check_form_data({'author': 'Jack', 'url': '/site3', 'message': 'comment 3', 'extra': 'ball'})

33
tests/test_imap.py Normal file
View file

@ -0,0 +1,33 @@
#!/usr/bin/python
# -*- coding: UTF-8 -*-
import datetime
import unittest
from email.header import Header
from email.message import Message
from stacosys.core import imap
class ImapTestCase(unittest.TestCase):
def test_utf8_decode(self):
h = Header(s="Chez Darty vous avez re\udcc3\udca7u un nouvel aspirateur Vacuum gratuit jl8nz",
charset="unknown-8bit")
decoded = imap._email_non_ascii_to_uft8(h)
self.assertEqual(decoded, "Chez Darty vous avez reçu un nouvel aspirateur Vacuum gratuit jl8nz")
def test_parse_date(self):
now = datetime.datetime.now()
self.assertGreaterEqual(imap._parse_date(None), now)
parsed = imap._parse_date("Wed, 8 Dec 2021 20:05:20 +0100")
self.assertEqual(parsed.day, 8)
self.assertEqual(parsed.month, 12)
self.assertEqual(parsed.year, 2021)
# do not compare hours. don't care about timezone
def test_to_plain_text_content(self):
msg = Message()
payload = b"non\r\n\r\nLe 08/12/2021 \xc3\xa0 20:04, kianby@free.fr a \xc3\xa9crit\xc2\xa0:\r\n> Bonjour,\r\n>\r\n> Un nouveau commentaire a \xc3\xa9t\xc3\xa9 post\xc3\xa9 pour l'article /2021/rester-discret-sur-github//\r\n>\r\n> Vous avez deux r\xc3\xa9ponses possibles :\r\n> - rejeter le commentaire en r\xc3\xa9pondant NO (ou no),\r\n> - accepter le commentaire en renvoyant cet email tel quel.\r\n>\r\n> Si cette derni\xc3\xa8re option est choisie, Stacosys publiera le commentaire tr\xc3\xa8s bient\xc3\xb4t.\r\n>\r\n> Voici les d\xc3\xa9tails concernant le commentaire :\r\n>\r\n> author: ET Rate\r\n> site:\r\n> date: 2021-12-08 20:03:58\r\n> url: /2021/rester-discret-sur-github//\r\n>\r\n> gfdgdgf\r\n>\r\n>\r\n> --\r\n> Stacosys\r\n"
msg.set_payload(payload, "UTF-8")
self.assertTrue(imap._to_plain_text_content(msg))

View file

@ -1,14 +0,0 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
import pytest
from stacosys.service.mail import Mailer
def test_configure_and_check():
mailer = Mailer()
mailer.configure_smtp("localhost", 2525, "admin", "admin")
mailer.configure_destination("admin@mydomain.com")
with pytest.raises(ConnectionRefusedError):
mailer.check()

View file

@ -1,9 +0,0 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
from stacosys.service.rssfeed import Rss
def test_configure():
rss = Rss()
rss.configure("comments.xml", "blog", "http", "blog.mydomain.com")

9
tests/test_stacosys.py Normal file
View file

@ -0,0 +1,9 @@
import unittest
from stacosys import __version__
class StacosysTestCase(unittest.TestCase):
def test_version(self):
self.assertEqual("2.0", __version__)

52
tests/test_templater.py Normal file
View file

@ -0,0 +1,52 @@
#!/usr/bin/python
# -*- coding: UTF-8 -*-
import os
import unittest
from stacosys.core.templater import Templater, Template
class TemplateTestCase(unittest.TestCase):
def get_template_content(self, lang, template_name, **kwargs):
current_path = os.path.dirname(__file__)
template_path = os.path.abspath(os.path.join(current_path, "../stacosys/templates"))
template = Templater(template_path).get_template(lang, template_name)
return template.render(kwargs)
def test_approve_comment(self):
content = self.get_template_content("fr", Template.APPROVE_COMMENT, original="[texte]")
self.assertTrue(content.startswith("Bonjour,\n\nLe commentaire sera bientôt publié."))
self.assertTrue(content.endswith("[texte]"))
content = self.get_template_content("en", Template.APPROVE_COMMENT, original="[texte]")
self.assertTrue(content.startswith("Hi,\n\nThe comment should be published soon."))
self.assertTrue(content.endswith("[texte]"))
def test_drop_comment(self):
content = self.get_template_content("fr", Template.DROP_COMMENT, original="[texte]")
self.assertTrue(content.startswith("Bonjour,\n\nLe commentaire ne sera pas publié."))
self.assertTrue(content.endswith("[texte]"))
content = self.get_template_content("en", Template.DROP_COMMENT, original="[texte]")
self.assertTrue(content.startswith("Hi,\n\nThe comment will not be published."))
self.assertTrue(content.endswith("[texte]"))
def test_new_comment(self):
content = self.get_template_content("fr", Template.NEW_COMMENT, comment="[comment]")
self.assertTrue(content.startswith("Bonjour,\n\nUn nouveau commentaire a été posté"))
self.assertTrue(content.endswith("[comment]\n\n--\nStacosys"))
content = self.get_template_content("en", Template.NEW_COMMENT, comment="[comment]")
self.assertTrue(content.startswith("Hi,\n\nA new comment has been submitted"))
self.assertTrue(content.endswith("[comment]\n\n--\nStacosys"))
def test_notify_message(self):
content = self.get_template_content("fr", Template.NOTIFY_MESSAGE)
self.assertEqual("Nouveau commentaire", content)
content = self.get_template_content("en", Template.NOTIFY_MESSAGE)
self.assertEqual("New comment", content)
def test_rss_title(self):
content = self.get_template_content("fr", Template.RSS_TITLE_MESSAGE, site="[site]")
self.assertEqual("[site] : commentaires", content)
content = self.get_template_content("en", Template.RSS_TITLE_MESSAGE, site="[site]")
self.assertEqual("[site] : comments", content)

567
uv.lock generated
View file

@ -1,567 +0,0 @@
version = 1
revision = 1
requires-python = ">=3.13.1"
[[package]]
name = "astroid"
version = "3.3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/40/e028137cb19ed577001c76b91c5c50fee5a9c85099f45820b69385574ac5/astroid-3.3.6.tar.gz", hash = "sha256:6aaea045f938c735ead292204afdb977a36e989522b7833ef6fea94de743f442", size = 397452 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/d2/82c8ccef22ea873a2b0da9636e47d45137eeeb2fb9320c5dbbdd3627bab0/astroid-3.3.6-py3-none-any.whl", hash = "sha256:db676dc4f3ae6bfe31cda227dc60e03438378d7a896aec57422c95634e8d722f", size = 274644 },
]
[[package]]
name = "background"
version = "0.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/41/d6122c8e4bb280b2182098d77554d00016b6ffe54201cd3fac7f52fe9df2/background-0.2.1.tar.gz", hash = "sha256:4a5ed40b4a2a9f3340b1402862725d35016dc2490f95d89a2de47c3ddf215b91", size = 3141 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/45/01a33c692ce9f22214cad440f34704ed74e56b6f21d90e71aa595b3c2b72/background-0.2.1-py3-none-any.whl", hash = "sha256:c230e2813c773f93ecae54281ce6b1b425c895c24599cc203b7f137e4d7c4802", size = 2209 },
]
[[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 = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 },
]
[[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 = "sys_platform == 'win32'" },
]
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 = "coverage"
version = "7.6.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/d2/c25011f4d036cf7e8acbbee07a8e09e9018390aee25ba085596c4b83d510/coverage-7.6.9.tar.gz", hash = "sha256:4a8d8977b0c6ef5aeadcb644da9e69ae0dcfe66ec7f368c89c72e058bd71164d", size = 801710 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/26/9abab6539d2191dbda2ce8c97b67d74cbfc966cc5b25abb880ffc7c459bc/coverage-7.6.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:899b8cd4781c400454f2f64f7776a5d87bbd7b3e7f7bda0cb18f857bb1334664", size = 207356 },
{ url = "https://files.pythonhosted.org/packages/44/da/d49f19402240c93453f606e660a6676a2a1fbbaa6870cc23207790aa9697/coverage-7.6.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61f70dc68bd36810972e55bbbe83674ea073dd1dcc121040a08cdf3416c5349c", size = 207614 },
{ url = "https://files.pythonhosted.org/packages/da/e6/93bb9bf85497816082ec8da6124c25efa2052bd4c887dd3b317b91990c9e/coverage-7.6.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a289d23d4c46f1a82d5db4abeb40b9b5be91731ee19a379d15790e53031c014", size = 240129 },
{ url = "https://files.pythonhosted.org/packages/df/65/6a824b9406fe066835c1274a9949e06f084d3e605eb1a602727a27ec2fe3/coverage-7.6.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e216d8044a356fc0337c7a2a0536d6de07888d7bcda76febcb8adc50bdbbd00", size = 237276 },
{ url = "https://files.pythonhosted.org/packages/9f/79/6c7a800913a9dd23ac8c8da133ebb556771a5a3d4df36b46767b1baffd35/coverage-7.6.9-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c026eb44f744acaa2bda7493dad903aa5bf5fc4f2554293a798d5606710055d", size = 239267 },
{ url = "https://files.pythonhosted.org/packages/57/e7/834d530293fdc8a63ba8ff70033d5182022e569eceb9aec7fc716b678a39/coverage-7.6.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e77363e8425325384f9d49272c54045bbed2f478e9dd698dbc65dbc37860eb0a", size = 238887 },
{ url = "https://files.pythonhosted.org/packages/15/05/ec9d6080852984f7163c96984444e7cd98b338fd045b191064f943ee1c08/coverage-7.6.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:777abfab476cf83b5177b84d7486497e034eb9eaea0d746ce0c1268c71652077", size = 236970 },
{ url = "https://files.pythonhosted.org/packages/0a/d8/775937670b93156aec29f694ce37f56214ed7597e1a75b4083ee4c32121c/coverage-7.6.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:447af20e25fdbe16f26e84eb714ba21d98868705cb138252d28bc400381f6ffb", size = 238831 },
{ url = "https://files.pythonhosted.org/packages/f4/58/88551cb7fdd5ec98cb6044e8814e38583436b14040a5ece15349c44c8f7c/coverage-7.6.9-cp313-cp313-win32.whl", hash = "sha256:d872ec5aeb086cbea771c573600d47944eea2dcba8be5f3ee649bfe3cb8dc9ba", size = 210000 },
{ url = "https://files.pythonhosted.org/packages/b7/12/cfbf49b95120872785ff8d56ab1c7fe3970a65e35010c311d7dd35c5fd00/coverage-7.6.9-cp313-cp313-win_amd64.whl", hash = "sha256:fd1213c86e48dfdc5a0cc676551db467495a95a662d2396ecd58e719191446e1", size = 210753 },
{ url = "https://files.pythonhosted.org/packages/7c/68/c1cb31445599b04bde21cbbaa6d21b47c5823cdfef99eae470dfce49c35a/coverage-7.6.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9e7484d286cd5a43744e5f47b0b3fb457865baf07bafc6bee91896364e1419", size = 208091 },
{ url = "https://files.pythonhosted.org/packages/11/73/84b02c6b19c4a11eb2d5b5eabe926fb26c21c080e0852f5e5a4f01165f9e/coverage-7.6.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1cf0872ee455c03e5674b5bca5e3e68e159379c1af0903e89f5eba9ccc3a", size = 208369 },
{ url = "https://files.pythonhosted.org/packages/de/e0/ae5d878b72ff26df2e994a5c5b1c1f6a7507d976b23beecb1ed4c85411ef/coverage-7.6.9-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d10e07aa2b91835d6abec555ec8b2733347956991901eea6ffac295f83a30e4", size = 251089 },
{ url = "https://files.pythonhosted.org/packages/ab/9c/0aaac011aef95a93ef3cb2fba3fde30bc7e68a6635199ed469b1f5ea355a/coverage-7.6.9-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13a9e2d3ee855db3dd6ea1ba5203316a1b1fd8eaeffc37c5b54987e61e4194ae", size = 246806 },
{ url = "https://files.pythonhosted.org/packages/f8/19/4d5d3ae66938a7dcb2f58cef3fa5386f838f469575b0bb568c8cc9e3a33d/coverage-7.6.9-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c38bf15a40ccf5619fa2fe8f26106c7e8e080d7760aeccb3722664c8656b030", size = 249164 },
{ url = "https://files.pythonhosted.org/packages/b3/0b/4ee8a7821f682af9ad440ae3c1e379da89a998883271f088102d7ca2473d/coverage-7.6.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d5275455b3e4627c8e7154feaf7ee0743c2e7af82f6e3b561967b1cca755a0be", size = 248642 },
{ url = "https://files.pythonhosted.org/packages/8a/12/36ff1d52be18a16b4700f561852e7afd8df56363a5edcfb04cf26a0e19e0/coverage-7.6.9-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8f8770dfc6e2c6a2d4569f411015c8d751c980d17a14b0530da2d7f27ffdd88e", size = 246516 },
{ url = "https://files.pythonhosted.org/packages/43/d0/8e258f6c3a527c1655602f4f576215e055ac704de2d101710a71a2affac2/coverage-7.6.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8d2dfa71665a29b153a9681edb1c8d9c1ea50dfc2375fb4dac99ea7e21a0bcd9", size = 247783 },
{ url = "https://files.pythonhosted.org/packages/a9/0d/1e4a48d289429d38aae3babdfcadbf35ca36bdcf3efc8f09b550a845bdb5/coverage-7.6.9-cp313-cp313t-win32.whl", hash = "sha256:5e6b86b5847a016d0fbd31ffe1001b63355ed309651851295315031ea7eb5a9b", size = 210646 },
{ url = "https://files.pythonhosted.org/packages/26/74/b0729f196f328ac55e42b1e22ec2f16d8bcafe4b8158a26ec9f1cdd1d93e/coverage-7.6.9-cp313-cp313t-win_amd64.whl", hash = "sha256:97ddc94d46088304772d21b060041c97fc16bdda13c6c7f9d8fcd8d5ae0d8611", size = 211815 },
]
[[package]]
name = "coveralls"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "docopt" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/75/a454fb443eb6a053833f61603a432ffbd7dd6ae53a11159bacfadb9d6219/coveralls-4.0.1.tar.gz", hash = "sha256:7b2a0a2bcef94f295e3cf28dcc55ca40b71c77d1c2446b538e85f0f7bc21aa69", size = 12419 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/e5/6708c75e2a4cfca929302d4d9b53b862c6dc65bd75e6933ea3d20016d41d/coveralls-4.0.1-py3-none-any.whl", hash = "sha256:7a6b1fa9848332c7b2221afb20f3df90272ac0167060f41b5fe90429b30b1809", size = 13599 },
]
[[package]]
name = "defusedxml"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 },
]
[[package]]
name = "dill"
version = "0.3.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/70/43/86fe3f9e130c4137b0f1b50784dd70a5087b911fe07fa81e53e0c4c47fea/dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c", size = 187000 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/d1/e73b6ad76f0b1fb7f23c35c6d95dbc506a9c8804f43dda8cb5b0fa6331fd/dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a", size = 119418 },
]
[[package]]
name = "docopt"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901 }
[[package]]
name = "flask"
version = "3.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/50/dff6380f1c7f84135484e176e0cac8690af72fa90e932ad2a0a60e28c69b/flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", size = 680824 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979 },
]
[[package]]
name = "genbadge"
version = "1.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "pillow" },
{ name = "requests" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e8/2b/75c50383f951f36334635715819f89d1b4dae1de0ff7d510970bbf137994/genbadge-1.1.2.tar.gz", hash = "sha256:987ed2feaf6e9cc2850fc3883320d8706b3849eb6c9f436156254dcac438515c", size = 137188 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/5e/91f2340d7a51ce0b7a59e5caa1cccd61131d8d5163cc02f3563c819cb49c/genbadge-1.1.2-py2.py3-none-any.whl", hash = "sha256:4e3073cb56c2745fbef4b7da97eb85b28a18a22af519b66acb6706b6546279f1", size = 100945 },
]
[[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 = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
]
[[package]]
name = "isort"
version = "5.13.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310 },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 },
]
[[package]]
name = "jinja2"
version = "3.1.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 },
]
[[package]]
name = "markdown"
version = "3.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/28/3af612670f82f4c056911fbbbb42760255801b3068c48de792d354ff4472/markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2", size = 357086 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/08/83871f3c50fc983b88547c196d11cf8c3340e37c32d2e9d6152abe2c61f7/Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803", size = 106349 },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
]
[[package]]
name = "mccabe"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 },
]
[[package]]
name = "mypy"
version = "1.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 },
{ url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 },
{ url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 },
{ url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 },
{ url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 },
{ url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 },
]
[[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 = "pillow"
version = "11.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/af/c097e544e7bd278333db77933e535098c259609c4eb3b85381109602fb5b/pillow-11.1.0.tar.gz", hash = "sha256:368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20", size = 46742715 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/31/9ca79cafdce364fd5c980cd3416c20ce1bebd235b470d262f9d24d810184/pillow-11.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae98e14432d458fc3de11a77ccb3ae65ddce70f730e7c76140653048c71bfcbc", size = 3226640 },
{ url = "https://files.pythonhosted.org/packages/ac/0f/ff07ad45a1f172a497aa393b13a9d81a32e1477ef0e869d030e3c1532521/pillow-11.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc1331b6d5a6e144aeb5e626f4375f5b7ae9934ba620c0ac6b3e43d5e683a0f0", size = 3101437 },
{ url = "https://files.pythonhosted.org/packages/08/2f/9906fca87a68d29ec4530be1f893149e0cb64a86d1f9f70a7cfcdfe8ae44/pillow-11.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:758e9d4ef15d3560214cddbc97b8ef3ef86ce04d62ddac17ad39ba87e89bd3b1", size = 4326605 },
{ url = "https://files.pythonhosted.org/packages/b0/0f/f3547ee15b145bc5c8b336401b2d4c9d9da67da9dcb572d7c0d4103d2c69/pillow-11.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b523466b1a31d0dcef7c5be1f20b942919b62fd6e9a9be199d035509cbefc0ec", size = 4411173 },
{ url = "https://files.pythonhosted.org/packages/b1/df/bf8176aa5db515c5de584c5e00df9bab0713548fd780c82a86cba2c2fedb/pillow-11.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9044b5e4f7083f209c4e35aa5dd54b1dd5b112b108648f5c902ad586d4f945c5", size = 4369145 },
{ url = "https://files.pythonhosted.org/packages/de/7c/7433122d1cfadc740f577cb55526fdc39129a648ac65ce64db2eb7209277/pillow-11.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3764d53e09cdedd91bee65c2527815d315c6b90d7b8b79759cc48d7bf5d4f114", size = 4496340 },
{ url = "https://files.pythonhosted.org/packages/25/46/dd94b93ca6bd555588835f2504bd90c00d5438fe131cf01cfa0c5131a19d/pillow-11.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31eba6bbdd27dde97b0174ddf0297d7a9c3a507a8a1480e1e60ef914fe23d352", size = 4296906 },
{ url = "https://files.pythonhosted.org/packages/a8/28/2f9d32014dfc7753e586db9add35b8a41b7a3b46540e965cb6d6bc607bd2/pillow-11.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5d658fbd9f0d6eea113aea286b21d3cd4d3fd978157cbf2447a6035916506d3", size = 4431759 },
{ url = "https://files.pythonhosted.org/packages/33/48/19c2cbe7403870fbe8b7737d19eb013f46299cdfe4501573367f6396c775/pillow-11.1.0-cp313-cp313-win32.whl", hash = "sha256:f86d3a7a9af5d826744fabf4afd15b9dfef44fe69a98541f666f66fbb8d3fef9", size = 2291657 },
{ url = "https://files.pythonhosted.org/packages/3b/ad/285c556747d34c399f332ba7c1a595ba245796ef3e22eae190f5364bb62b/pillow-11.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:593c5fd6be85da83656b93ffcccc2312d2d149d251e98588b14fbc288fd8909c", size = 2626304 },
{ url = "https://files.pythonhosted.org/packages/e5/7b/ef35a71163bf36db06e9c8729608f78dedf032fc8313d19bd4be5c2588f3/pillow-11.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:11633d58b6ee5733bde153a8dafd25e505ea3d32e261accd388827ee987baf65", size = 2375117 },
{ url = "https://files.pythonhosted.org/packages/79/30/77f54228401e84d6791354888549b45824ab0ffde659bafa67956303a09f/pillow-11.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70ca5ef3b3b1c4a0812b5c63c57c23b63e53bc38e758b37a951e5bc466449861", size = 3230060 },
{ url = "https://files.pythonhosted.org/packages/ce/b1/56723b74b07dd64c1010fee011951ea9c35a43d8020acd03111f14298225/pillow-11.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8000376f139d4d38d6851eb149b321a52bb8893a88dae8ee7d95840431977081", size = 3106192 },
{ url = "https://files.pythonhosted.org/packages/e1/cd/7bf7180e08f80a4dcc6b4c3a0aa9e0b0ae57168562726a05dc8aa8fa66b0/pillow-11.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee85f0696a17dd28fbcfceb59f9510aa71934b483d1f5601d1030c3c8304f3c", size = 4446805 },
{ url = "https://files.pythonhosted.org/packages/97/42/87c856ea30c8ed97e8efbe672b58c8304dee0573f8c7cab62ae9e31db6ae/pillow-11.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:dd0e081319328928531df7a0e63621caf67652c8464303fd102141b785ef9547", size = 4530623 },
{ url = "https://files.pythonhosted.org/packages/ff/41/026879e90c84a88e33fb00cc6bd915ac2743c67e87a18f80270dfe3c2041/pillow-11.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e63e4e5081de46517099dc30abe418122f54531a6ae2ebc8680bcd7096860eab", size = 4465191 },
{ url = "https://files.pythonhosted.org/packages/e5/fb/a7960e838bc5df57a2ce23183bfd2290d97c33028b96bde332a9057834d3/pillow-11.1.0-cp313-cp313t-win32.whl", hash = "sha256:dda60aa465b861324e65a78c9f5cf0f4bc713e4309f83bc387be158b077963d9", size = 2295494 },
{ url = "https://files.pythonhosted.org/packages/d7/6c/6ec83ee2f6f0fda8d4cf89045c6be4b0373ebfc363ba8538f8c999f63fcd/pillow-11.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ad5db5781c774ab9a9b2c4302bbf0c1014960a0a7be63278d13ae6fdf88126fe", size = 2631595 },
{ url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651 },
]
[[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 = "pluggy"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
]
[[package]]
name = "pydal"
version = "20241204.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/78/7ddf9aacea5cd2e63423d278d26465c63ecdae87cf1c503d8fc1f7dfcfa5/pydal-20241204.1.tar.gz", hash = "sha256:1ba1f9e528b985e234f5b3acfd9d549998b44f7ed7ae747b9e8d4ad3047bf511", size = 623731 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/de/30f6ee6c8f333a00969fb4d5cd3c8cb8ca69feeeb2518d69b69d9bbe732b/pydal-20241204.1-py2.py3-none-any.whl", hash = "sha256:416f06de17ab0a5340e11195a0583abfe484eceb067cd3ab92208d3dc5aa7683", size = 246873 },
]
[[package]]
name = "pylint"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "astroid" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "dill" },
{ name = "isort" },
{ name = "mccabe" },
{ name = "platformdirs" },
{ name = "tomlkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/d8/4471b2cb4ad18b4af717918c468209bd2bd5a02c52f60be5ee8a71b5af2c/pylint-3.3.2.tar.gz", hash = "sha256:9ec054ec992cd05ad30a6df1676229739a73f8feeabf3912c995d17601052b01", size = 1516485 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/55/5eaf6c415f6ddb09b9b039278823a8e27fb81ea7a34ec80c6d9223b17f2e/pylint-3.3.2-py3-none-any.whl", hash = "sha256:77f068c287d49b8683cd7c6e624243c74f92890f767f106ffa1ddf3c0a54cb7a", size = 521873 },
]
[[package]]
name = "pyrss2gen"
version = "1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/01/fd610d5fc86f7dbdbefc4baa8f7fe15a2e5484244c41dcf363ca7e89f60c/PyRSS2Gen-1.1.tar.gz", hash = "sha256:7960aed7e998d2482bf58716c316509786f596426f879b05f8d84e98b82c6ee7", size = 6854 }
[[package]]
name = "pytest"
version = "8.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 },
]
[[package]]
name = "pytest-cov"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 },
]
[[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 = "setuptools"
version = "78.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/5a/0db4da3bc908df06e5efae42b44e75c81dd52716e10192ff36d0c1c8e379/setuptools-78.1.0.tar.gz", hash = "sha256:18fd474d4a82a5f83dac888df697af65afa82dec7323d09c3e37d1f14288da54", size = 1367827 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/21/f43f0a1fa8b06b32812e0975981f4677d28e0f3271601dc88ac5a5b83220/setuptools-78.1.0-py3-none-any.whl", hash = "sha256:3e386e96793c8702ae83d17b853fb93d3e09ef82ec62722e61da5cd22376dcd8", size = 1256108 },
]
[[package]]
name = "stacosys"
version = "3.4"
source = { editable = "." }
dependencies = [
{ name = "background" },
{ name = "defusedxml" },
{ name = "flask" },
{ name = "genbadge" },
{ name = "markdown" },
{ name = "pydal" },
{ name = "pyrss2gen" },
{ name = "requests" },
{ name = "types-markdown" },
]
[package.dev-dependencies]
dev = [
{ name = "black" },
{ name = "coveralls" },
{ name = "mypy" },
{ name = "pylint" },
{ name = "pytest" },
{ name = "pytest-cov" },
]
[package.metadata]
requires-dist = [
{ name = "background", specifier = ">=0.2.1" },
{ name = "defusedxml", specifier = ">=0.7.1" },
{ name = "flask", specifier = ">=3.1.0" },
{ name = "genbadge", specifier = ">=1.1.2" },
{ name = "markdown", specifier = ">=3.7" },
{ name = "pydal", specifier = ">=20241204.1" },
{ name = "pyrss2gen", specifier = ">=1.1" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "types-markdown", specifier = ">=3.7.0.20241204" },
]
[package.metadata.requires-dev]
dev = [
{ name = "black", specifier = ">=24.10.0" },
{ name = "coveralls", specifier = ">=4.0.1" },
{ name = "mypy", specifier = ">=1.13.0" },
{ name = "pylint", specifier = ">=3.3.2" },
{ name = "pytest", specifier = ">=8.3.4" },
{ name = "pytest-cov", specifier = ">=6.0.0" },
]
[[package]]
name = "tomlkit"
version = "0.13.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 },
]
[[package]]
name = "types-markdown"
version = "3.7.0.20241204"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/3c/874ac6ce93f4e6bd0283a5df2c8065f4e623c6c3bc0b2fb98c098313cb73/types_markdown-3.7.0.20241204.tar.gz", hash = "sha256:ecca2b25cd23163fd28ed5ba34d183d731da03e8a5ed3a20b60daded304c5410", size = 17820 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/26/3c9730e845cfd0d587e0dfa9c1975f02f9f49407afbf30800094bdac0286/types_Markdown-3.7.0.20241204-py3-none-any.whl", hash = "sha256:f96146c367ea9c82bfe9903559d72706555cc2a1a3474c58ebba03b418ab18da", size = 23572 },
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
]
[[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 },
]
[[package]]
name = "werkzeug"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 },
]