diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..bbee4cd
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -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
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
new file mode 100644
index 0000000..6b4ba3b
--- /dev/null
+++ b/.github/workflows/pytest.yml
@@ -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 }}
diff --git a/.gitignore b/.gitignore
index 9d89a5a..2f5cfda 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
\ No newline at end of file
diff --git a/.pylintrc b/.pylintrc
deleted file mode 100644
index 75dae48..0000000
--- a/.pylintrc
+++ /dev/null
@@ -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*(# )??$
-
-# 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
diff --git a/Dockerfile b/Dockerfile
index f3a448e..a025b04 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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}
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 7b6b402..0000000
--- a/Makefile
+++ /dev/null
@@ -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 config-dev.ini
diff --git a/README.md b/README.md
index 6986c76..321fe82 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,13 @@
-[](https://www.gnu.org/licenses/gpl-3.0)
- [](https://www.python.org/) [](https://github.com/psf/black) [](https://flask.palletsprojects.com)
+[](https://gitlicense.com/license/kianby/stacosys)
+ [](https://www.python.org/) [](https://flask.palletsprojects.com) [](https://docs.peewee-orm.com/)
-[]() []()
+[](https://github.com/kianby/stacosys) [](https://coveralls.io/github/kianby/stacosys?branch=main) [](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.
diff --git a/config.ini b/config.ini
index f6ff236..80f7e56 100755
--- a/config.ini
+++ b/config.ini
@@ -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
diff --git a/coverage-badge.svg b/coverage-badge.svg
deleted file mode 100644
index dcb99ba..0000000
--- a/coverage-badge.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/dbmigration/create_empty_db.py b/dbmigration/create_empty_db.py
deleted file mode 100644
index 43a317c..0000000
--- a/dbmigration/create_empty_db.py
+++ /dev/null
@@ -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()
diff --git a/dbmigration/migrate_from_1.1_to_2.0.py b/dbmigration/migrate_from_1.1_to_2.0.py
index e4618c8..396f576 100644
--- a/dbmigration/migrate_from_1.1_to_2.0.py
+++ b/dbmigration/migrate_from_1.1_to_2.0.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/python
# -*- coding: UTF-8 -*-
import sqlite3
@@ -6,30 +6,27 @@ import sqlite3
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
+# What script performs:
+# - 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;
DROP TABLE site;
ALTER TABLE comment RENAME TO _comment_old;
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
+ 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
);
-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;
@@ -37,4 +34,4 @@ PRAGMA foreign_keys = ON;
"""
cursor.executescript(script)
-connection.close()
+connection.close()
\ No newline at end of file
diff --git a/docker/docker-init.sh b/docker/docker-init.sh
index 7c51f8e..b573f35 100644
--- a/docker/docker-init.sh
+++ b/docker/docker-init.sh
@@ -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
\ No newline at end of file
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..be7fec3
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,1044 @@
+[[package]]
+name = "appdirs"
+version = "1.4.4"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "apscheduler"
+version = "3.8.1"
+description = "In-process task scheduler with Cron-like capabilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
+
+[package.dependencies]
+pytz = "*"
+six = ">=1.4.0"
+tzlocal = ">=2.0,<3.0.0 || >=4.0.0"
+
+[package.extras]
+asyncio = ["trollius"]
+doc = ["sphinx", "sphinx-rtd-theme"]
+gevent = ["gevent"]
+mongodb = ["pymongo (>=3.0)"]
+redis = ["redis (>=3.0)"]
+rethinkdb = ["rethinkdb (>=2.4.0)"]
+sqlalchemy = ["sqlalchemy (>=0.8)"]
+testing = ["pytest (<6)", "pytest-cov", "pytest-tornado5", "mock", "pytest-asyncio (<0.6)", "pytest-asyncio"]
+tornado = ["tornado (>=4.3)"]
+twisted = ["twisted"]
+zookeeper = ["kazoo"]
+
+[[package]]
+name = "atomicwrites"
+version = "1.4.0"
+description = "Atomic file writes."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "attrs"
+version = "21.2.0"
+description = "Classes Without Boilerplate"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.extras]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"]
+docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
+
+[[package]]
+name = "black"
+version = "20.8b1"
+description = "The uncompromising code formatter."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+appdirs = "*"
+click = ">=7.1.2"
+mypy-extensions = ">=0.4.3"
+pathspec = ">=0.6,<1"
+regex = ">=2020.1.8"
+toml = ">=0.10.1"
+typed-ast = ">=1.4.0"
+typing-extensions = ">=3.7.4"
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
+
+[[package]]
+name = "certifi"
+version = "2021.10.8"
+description = "Python package for providing Mozilla's CA Bundle."
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "charset-normalizer"
+version = "2.0.9"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+category = "main"
+optional = false
+python-versions = ">=3.5.0"
+
+[package.extras]
+unicode_backport = ["unicodedata2"]
+
+[[package]]
+name = "click"
+version = "8.0.3"
+description = "Composable command line interface toolkit"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.4"
+description = "Cross-platform colored terminal text."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "coverage"
+version = "5.5"
+description = "Code coverage measurement for Python"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
+
+[package.extras]
+toml = ["toml"]
+
+[[package]]
+name = "coveralls"
+version = "3.3.1"
+description = "Show coverage stats online via coveralls.io"
+category = "dev"
+optional = false
+python-versions = ">= 3.5"
+
+[package.dependencies]
+coverage = ">=4.1,<6.0.0 || >6.1,<6.1.1 || >6.1.1,<7.0"
+docopt = ">=0.6.1"
+requests = ">=1.0.0"
+
+[package.extras]
+yaml = ["PyYAML (>=3.10)"]
+
+[[package]]
+name = "docopt"
+version = "0.6.2"
+description = "Pythonic argument parser, that will make you smile"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "flake8"
+version = "4.0.1"
+description = "the modular source code checker: pep8 pyflakes and co"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+mccabe = ">=0.6.0,<0.7.0"
+pycodestyle = ">=2.8.0,<2.9.0"
+pyflakes = ">=2.4.0,<2.5.0"
+
+[[package]]
+name = "flake8-black"
+version = "0.2.3"
+description = "flake8 plugin to call black as a code style validator"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+black = "*"
+flake8 = ">=3.0.0"
+toml = "*"
+
+[[package]]
+name = "flask"
+version = "2.0.2"
+description = "A simple framework for building complex web applications."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+click = ">=7.1.2"
+itsdangerous = ">=2.0"
+Jinja2 = ">=3.0"
+Werkzeug = ">=2.0"
+
+[package.extras]
+async = ["asgiref (>=3.2)"]
+dotenv = ["python-dotenv"]
+
+[[package]]
+name = "flask-apscheduler"
+version = "1.12.2"
+description = "Adds APScheduler support to Flask"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+apscheduler = ">=3.2.0,<4.0.0"
+flask = ">=0.10.1"
+python-dateutil = ">=2.4.2"
+
+[[package]]
+name = "idna"
+version = "3.3"
+description = "Internationalized Domain Names in Applications (IDNA)"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "importlib-metadata"
+version = "4.8.2"
+description = "Read metadata from Python packages"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+zipp = ">=0.5"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+perf = ["ipython"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
+
+[[package]]
+name = "iniconfig"
+version = "1.1.1"
+description = "iniconfig: brain-dead simple config-ini parsing"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "itsdangerous"
+version = "2.0.1"
+description = "Safely pass data to untrusted environments and back."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "jinja2"
+version = "3.0.3"
+description = "A very fast and expressive template engine."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "markdown"
+version = "3.3.6"
+description = "Python implementation of Markdown."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""}
+
+[package.extras]
+testing = ["coverage", "pyyaml"]
+
+[[package]]
+name = "markupsafe"
+version = "2.0.1"
+description = "Safely add untrusted strings to HTML/XML markup."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "mccabe"
+version = "0.6.1"
+description = "McCabe checker, plugin for flake8"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "mypy"
+version = "0.790"
+description = "Optional static typing for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[package.dependencies]
+mypy-extensions = ">=0.4.3,<0.5.0"
+typed-ast = ">=1.4.0,<1.5.0"
+typing-extensions = ">=3.7.4"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+
+[[package]]
+name = "mypy-extensions"
+version = "0.4.3"
+description = "Experimental type system extensions for programs checked with the mypy typechecker."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "packaging"
+version = "21.3"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
+
+[[package]]
+name = "pathspec"
+version = "0.9.0"
+description = "Utility library for gitignore style pattern matching of file paths."
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+
+[[package]]
+name = "peewee"
+version = "3.14.8"
+description = "a little orm"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "pluggy"
+version = "1.0.0"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "profig"
+version = "0.5.1"
+description = "A configuration library."
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "py"
+version = "1.11.0"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "pycodestyle"
+version = "2.8.0"
+description = "Python style guide checker"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "pyflakes"
+version = "2.4.0"
+description = "passive checker of Python programs"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "pyparsing"
+version = "3.0.6"
+description = "Python parsing module"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+diagrams = ["jinja2", "railroad-diagrams"]
+
+[[package]]
+name = "pyrss2gen"
+version = "1.1"
+description = "Generate RSS2 using a Python data structure"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "pytest"
+version = "6.2.5"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
+attrs = ">=19.2.0"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<2.0"
+py = ">=1.8.2"
+toml = "*"
+
+[package.extras]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
+
+[[package]]
+name = "pytest-cov"
+version = "2.12.1"
+description = "Pytest plugin for measuring coverage."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+coverage = ">=5.2.1"
+pytest = ">=4.6"
+toml = "*"
+
+[package.extras]
+testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"]
+
+[[package]]
+name = "python-dateutil"
+version = "2.8.2"
+description = "Extensions to the standard Python datetime module"
+category = "main"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "pytz"
+version = "2021.3"
+description = "World timezone definitions, modern and historical"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "pytz-deprecation-shim"
+version = "0.1.0.post0"
+description = "Shims to make deprecation of pytz easier"
+category = "main"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
+
+[package.dependencies]
+tzdata = {version = "*", markers = "python_version >= \"3.6\""}
+
+[[package]]
+name = "regex"
+version = "2021.11.10"
+description = "Alternative regular expression module, to replace re."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "requests"
+version = "2.26.0"
+description = "Python HTTP for Humans."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
+idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
+urllib3 = ">=1.21.1,<1.27"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
+use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
+
+[[package]]
+name = "rope"
+version = "0.16.0"
+description = "a python refactoring library..."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.extras]
+dev = ["pytest"]
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+category = "dev"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "typed-ast"
+version = "1.4.3"
+description = "a fork of Python 2 and 3 ast modules with type comment support"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "typing-extensions"
+version = "4.0.1"
+description = "Backported and Experimental Type Hints for Python 3.6+"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "tzdata"
+version = "2021.5"
+description = "Provider of IANA time zone data"
+category = "main"
+optional = false
+python-versions = ">=2"
+
+[[package]]
+name = "tzlocal"
+version = "4.1"
+description = "tzinfo object for the local timezone"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pytz-deprecation-shim = "*"
+tzdata = {version = "*", markers = "platform_system == \"Windows\""}
+
+[package.extras]
+devenv = ["black", "pyroma", "pytest-cov", "zest.releaser"]
+test = ["pytest-mock (>=3.3)", "pytest (>=4.3)"]
+
+[[package]]
+name = "urllib3"
+version = "1.26.7"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
+
+[package.extras]
+brotli = ["brotlipy (>=0.6.0)"]
+secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
+socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
+
+[[package]]
+name = "werkzeug"
+version = "2.0.2"
+description = "The comprehensive WSGI web application library."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+watchdog = ["watchdog"]
+
+[[package]]
+name = "zipp"
+version = "3.6.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
+
+[metadata]
+lock-version = "1.1"
+python-versions = "^3.9"
+content-hash = "b54f622e5630967a5ee4078dedf7ad331cc9e3b064bfcd71c839583c002be28f"
+
+[metadata.files]
+appdirs = [
+ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
+ {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
+]
+apscheduler = [
+ {file = "APScheduler-3.8.1-py2.py3-none-any.whl", hash = "sha256:c22cb14b411a31435eb2c530dfbbec948ac63015b517087c7978adb61b574865"},
+ {file = "APScheduler-3.8.1.tar.gz", hash = "sha256:5cf344ebcfbdaa48ae178c029c055cec7bc7a4a47c21e315e4d1f08bd35f2355"},
+]
+atomicwrites = [
+ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
+ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
+]
+attrs = [
+ {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
+ {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
+]
+black = [
+ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"},
+]
+certifi = [
+ {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
+ {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
+]
+charset-normalizer = [
+ {file = "charset-normalizer-2.0.9.tar.gz", hash = "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c"},
+ {file = "charset_normalizer-2.0.9-py3-none-any.whl", hash = "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721"},
+]
+click = [
+ {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"},
+ {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"},
+]
+colorama = [
+ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
+ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
+]
+coverage = [
+ {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"},
+ {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"},
+ {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"},
+ {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"},
+ {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"},
+ {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"},
+ {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"},
+ {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"},
+ {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"},
+ {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"},
+ {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"},
+ {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"},
+ {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"},
+ {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"},
+ {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"},
+ {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"},
+ {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"},
+ {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"},
+ {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"},
+ {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"},
+ {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"},
+ {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"},
+ {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"},
+ {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"},
+ {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"},
+ {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"},
+ {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"},
+ {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"},
+ {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"},
+ {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"},
+ {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"},
+ {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"},
+ {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"},
+ {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"},
+ {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"},
+ {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"},
+ {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"},
+ {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"},
+ {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"},
+ {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"},
+ {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"},
+ {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"},
+ {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"},
+ {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"},
+ {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"},
+ {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"},
+ {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"},
+ {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"},
+ {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"},
+ {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"},
+ {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"},
+ {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"},
+]
+coveralls = [
+ {file = "coveralls-3.3.1-py2.py3-none-any.whl", hash = "sha256:f42015f31d386b351d4226389b387ae173207058832fbf5c8ec4b40e27b16026"},
+ {file = "coveralls-3.3.1.tar.gz", hash = "sha256:b32a8bb5d2df585207c119d6c01567b81fba690c9c10a753bfe27a335bfc43ea"},
+]
+docopt = [
+ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"},
+]
+flake8 = [
+ {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
+ {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
+]
+flake8-black = [
+ {file = "flake8-black-0.2.3.tar.gz", hash = "sha256:c199844bc1b559d91195ebe8620216f21ed67f2cc1ff6884294c91a0d2492684"},
+ {file = "flake8_black-0.2.3-py3-none-any.whl", hash = "sha256:cc080ba5b3773b69ba102b6617a00cc4ecbad8914109690cfda4d565ea435d96"},
+]
+flask = [
+ {file = "Flask-2.0.2-py3-none-any.whl", hash = "sha256:cb90f62f1d8e4dc4621f52106613488b5ba826b2e1e10a33eac92f723093ab6a"},
+ {file = "Flask-2.0.2.tar.gz", hash = "sha256:7b2fb8e934ddd50731893bdcdb00fc8c0315916f9fcd50d22c7cc1a95ab634e2"},
+]
+flask-apscheduler = [
+ {file = "Flask-APScheduler-1.12.2.tar.gz", hash = "sha256:b9fe174b90d201d8beeba5522b023208f7bb6e2583fc02fea4be4bce5ee8f9e5"},
+]
+idna = [
+ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
+ {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
+]
+importlib-metadata = [
+ {file = "importlib_metadata-4.8.2-py3-none-any.whl", hash = "sha256:53ccfd5c134223e497627b9815d5030edf77d2ed573922f7a0b8f8bb81a1c100"},
+ {file = "importlib_metadata-4.8.2.tar.gz", hash = "sha256:75bdec14c397f528724c1bfd9709d660b33a4d2e77387a3358f20b848bb5e5fb"},
+]
+iniconfig = [
+ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
+ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
+]
+itsdangerous = [
+ {file = "itsdangerous-2.0.1-py3-none-any.whl", hash = "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c"},
+ {file = "itsdangerous-2.0.1.tar.gz", hash = "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0"},
+]
+jinja2 = [
+ {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"},
+ {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"},
+]
+markdown = [
+ {file = "Markdown-3.3.6-py3-none-any.whl", hash = "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"},
+ {file = "Markdown-3.3.6.tar.gz", hash = "sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006"},
+]
+markupsafe = [
+ {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"},
+ {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"},
+ {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"},
+ {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"},
+ {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"},
+ {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"},
+ {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"},
+]
+mccabe = [
+ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
+ {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
+]
+mypy = [
+ {file = "mypy-0.790-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:bd03b3cf666bff8d710d633d1c56ab7facbdc204d567715cb3b9f85c6e94f669"},
+ {file = "mypy-0.790-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:2170492030f6faa537647d29945786d297e4862765f0b4ac5930ff62e300d802"},
+ {file = "mypy-0.790-cp35-cp35m-win_amd64.whl", hash = "sha256:e86bdace26c5fe9cf8cb735e7cedfe7850ad92b327ac5d797c656717d2ca66de"},
+ {file = "mypy-0.790-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e97e9c13d67fbe524be17e4d8025d51a7dca38f90de2e462243ab8ed8a9178d1"},
+ {file = "mypy-0.790-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0d34d6b122597d48a36d6c59e35341f410d4abfa771d96d04ae2c468dd201abc"},
+ {file = "mypy-0.790-cp36-cp36m-win_amd64.whl", hash = "sha256:72060bf64f290fb629bd4a67c707a66fd88ca26e413a91384b18db3876e57ed7"},
+ {file = "mypy-0.790-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:eea260feb1830a627fb526d22fbb426b750d9f5a47b624e8d5e7e004359b219c"},
+ {file = "mypy-0.790-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c614194e01c85bb2e551c421397e49afb2872c88b5830e3554f0519f9fb1c178"},
+ {file = "mypy-0.790-cp37-cp37m-win_amd64.whl", hash = "sha256:0a0d102247c16ce93c97066443d11e2d36e6cc2a32d8ccc1f705268970479324"},
+ {file = "mypy-0.790-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cf4e7bf7f1214826cf7333627cb2547c0db7e3078723227820d0a2490f117a01"},
+ {file = "mypy-0.790-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:af4e9ff1834e565f1baa74ccf7ae2564ae38c8df2a85b057af1dbbc958eb6666"},
+ {file = "mypy-0.790-cp38-cp38-win_amd64.whl", hash = "sha256:da56dedcd7cd502ccd3c5dddc656cb36113dd793ad466e894574125945653cea"},
+ {file = "mypy-0.790-py3-none-any.whl", hash = "sha256:2842d4fbd1b12ab422346376aad03ff5d0805b706102e475e962370f874a5122"},
+ {file = "mypy-0.790.tar.gz", hash = "sha256:2b21ba45ad9ef2e2eb88ce4aeadd0112d0f5026418324176fd494a6824b74975"},
+]
+mypy-extensions = [
+ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
+ {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
+]
+packaging = [
+ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
+ {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
+]
+pathspec = [
+ {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
+ {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
+]
+peewee = [
+ {file = "peewee-3.14.8.tar.gz", hash = "sha256:01bd7f734defb08d7a3346a0c0ca7011bc8d0d685934ec0e001b3371d522ec53"},
+]
+pluggy = [
+ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
+ {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
+]
+profig = [
+ {file = "profig-0.5.1.tar.gz", hash = "sha256:cb9c094325a93505fc6325d13f3e679b281093223f143a96a6df8ad9c2bfc9a6"},
+]
+py = [
+ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
+ {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
+]
+pycodestyle = [
+ {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"},
+ {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"},
+]
+pyflakes = [
+ {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"},
+ {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"},
+]
+pyparsing = [
+ {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"},
+ {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"},
+]
+pyrss2gen = [
+ {file = "PyRSS2Gen-1.1.tar.gz", hash = "sha256:7960aed7e998d2482bf58716c316509786f596426f879b05f8d84e98b82c6ee7"},
+]
+pytest = [
+ {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"},
+ {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"},
+]
+pytest-cov = [
+ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"},
+ {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"},
+]
+python-dateutil = [
+ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
+ {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
+]
+pytz = [
+ {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"},
+ {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"},
+]
+pytz-deprecation-shim = [
+ {file = "pytz_deprecation_shim-0.1.0.post0-py2.py3-none-any.whl", hash = "sha256:8314c9692a636c8eb3bda879b9f119e350e93223ae83e70e80c31675a0fdc1a6"},
+ {file = "pytz_deprecation_shim-0.1.0.post0.tar.gz", hash = "sha256:af097bae1b616dde5c5744441e2ddc69e74dfdcb0c263129610d85b87445a59d"},
+]
+regex = [
+ {file = "regex-2021.11.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9345b6f7ee578bad8e475129ed40123d265464c4cfead6c261fd60fc9de00bcf"},
+ {file = "regex-2021.11.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:416c5f1a188c91e3eb41e9c8787288e707f7d2ebe66e0a6563af280d9b68478f"},
+ {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0538c43565ee6e703d3a7c3bdfe4037a5209250e8502c98f20fea6f5fdf2965"},
+ {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee1227cf08b6716c85504aebc49ac827eb88fcc6e51564f010f11a406c0a667"},
+ {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6650f16365f1924d6014d2ea770bde8555b4a39dc9576abb95e3cd1ff0263b36"},
+ {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30ab804ea73972049b7a2a5c62d97687d69b5a60a67adca07eb73a0ddbc9e29f"},
+ {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68a067c11463de2a37157930d8b153005085e42bcb7ad9ca562d77ba7d1404e0"},
+ {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:162abfd74e88001d20cb73ceaffbfe601469923e875caf9118333b1a4aaafdc4"},
+ {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9ed0b1e5e0759d6b7f8e2f143894b2a7f3edd313f38cf44e1e15d360e11749b"},
+ {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:473e67837f786404570eae33c3b64a4b9635ae9f00145250851a1292f484c063"},
+ {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2fee3ed82a011184807d2127f1733b4f6b2ff6ec7151d83ef3477f3b96a13d03"},
+ {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d5fd67df77bab0d3f4ea1d7afca9ef15c2ee35dfb348c7b57ffb9782a6e4db6e"},
+ {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5d408a642a5484b9b4d11dea15a489ea0928c7e410c7525cd892f4d04f2f617b"},
+ {file = "regex-2021.11.10-cp310-cp310-win32.whl", hash = "sha256:98ba568e8ae26beb726aeea2273053c717641933836568c2a0278a84987b2a1a"},
+ {file = "regex-2021.11.10-cp310-cp310-win_amd64.whl", hash = "sha256:780b48456a0f0ba4d390e8b5f7c661fdd218934388cde1a974010a965e200e12"},
+ {file = "regex-2021.11.10-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:dba70f30fd81f8ce6d32ddeef37d91c8948e5d5a4c63242d16a2b2df8143aafc"},
+ {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1f54b9b4b6c53369f40028d2dd07a8c374583417ee6ec0ea304e710a20f80a0"},
+ {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fbb9dc00e39f3e6c0ef48edee202f9520dafb233e8b51b06b8428cfcb92abd30"},
+ {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666abff54e474d28ff42756d94544cdfd42e2ee97065857413b72e8a2d6a6345"},
+ {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5537f71b6d646f7f5f340562ec4c77b6e1c915f8baae822ea0b7e46c1f09b733"},
+ {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2e07c6a26ed4bea91b897ee2b0835c21716d9a469a96c3e878dc5f8c55bb23"},
+ {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ca5f18a75e1256ce07494e245cdb146f5a9267d3c702ebf9b65c7f8bd843431e"},
+ {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:74cbeac0451f27d4f50e6e8a8f3a52ca074b5e2da9f7b505c4201a57a8ed6286"},
+ {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:3598893bde43091ee5ca0a6ad20f08a0435e93a69255eeb5f81b85e81e329264"},
+ {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:50a7ddf3d131dc5633dccdb51417e2d1910d25cbcf842115a3a5893509140a3a"},
+ {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:61600a7ca4bcf78a96a68a27c2ae9389763b5b94b63943d5158f2a377e09d29a"},
+ {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:563d5f9354e15e048465061509403f68424fef37d5add3064038c2511c8f5e00"},
+ {file = "regex-2021.11.10-cp36-cp36m-win32.whl", hash = "sha256:93a5051fcf5fad72de73b96f07d30bc29665697fb8ecdfbc474f3452c78adcf4"},
+ {file = "regex-2021.11.10-cp36-cp36m-win_amd64.whl", hash = "sha256:b483c9d00a565633c87abd0aaf27eb5016de23fed952e054ecc19ce32f6a9e7e"},
+ {file = "regex-2021.11.10-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fff55f3ce50a3ff63ec8e2a8d3dd924f1941b250b0aac3d3d42b687eeff07a8e"},
+ {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e32d2a2b02ccbef10145df9135751abea1f9f076e67a4e261b05f24b94219e36"},
+ {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53db2c6be8a2710b359bfd3d3aa17ba38f8aa72a82309a12ae99d3c0c3dcd74d"},
+ {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2207ae4f64ad3af399e2d30dde66f0b36ae5c3129b52885f1bffc2f05ec505c8"},
+ {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5ca078bb666c4a9d1287a379fe617a6dccd18c3e8a7e6c7e1eb8974330c626a"},
+ {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd33eb9bdcfbabab3459c9ee651d94c842bc8a05fabc95edf4ee0c15a072495e"},
+ {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:05b7d6d7e64efe309972adab77fc2af8907bb93217ec60aa9fe12a0dad35874f"},
+ {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:42b50fa6666b0d50c30a990527127334d6b96dd969011e843e726a64011485da"},
+ {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6e1d2cc79e8dae442b3fa4a26c5794428b98f81389af90623ffcc650ce9f6732"},
+ {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:0416f7399e918c4b0e074a0f66e5191077ee2ca32a0f99d4c187a62beb47aa05"},
+ {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:ce298e3d0c65bd03fa65ffcc6db0e2b578e8f626d468db64fdf8457731052942"},
+ {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dc07f021ee80510f3cd3af2cad5b6a3b3a10b057521d9e6aaeb621730d320c5a"},
+ {file = "regex-2021.11.10-cp37-cp37m-win32.whl", hash = "sha256:e71255ba42567d34a13c03968736c5d39bb4a97ce98188fafb27ce981115beec"},
+ {file = "regex-2021.11.10-cp37-cp37m-win_amd64.whl", hash = "sha256:07856afef5ffcc052e7eccf3213317fbb94e4a5cd8177a2caa69c980657b3cb4"},
+ {file = "regex-2021.11.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba05430e819e58544e840a68b03b28b6d328aff2e41579037e8bab7653b37d83"},
+ {file = "regex-2021.11.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7f301b11b9d214f83ddaf689181051e7f48905568b0c7017c04c06dfd065e244"},
+ {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aaa4e0705ef2b73dd8e36eeb4c868f80f8393f5f4d855e94025ce7ad8525f50"},
+ {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:788aef3549f1924d5c38263104dae7395bf020a42776d5ec5ea2b0d3d85d6646"},
+ {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8af619e3be812a2059b212064ea7a640aff0568d972cd1b9e920837469eb3cb"},
+ {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85bfa6a5413be0ee6c5c4a663668a2cad2cbecdee367630d097d7823041bdeec"},
+ {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f23222527b307970e383433daec128d769ff778d9b29343fb3496472dc20dabe"},
+ {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:da1a90c1ddb7531b1d5ff1e171b4ee61f6345119be7351104b67ff413843fe94"},
+ {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f5be7805e53dafe94d295399cfbe5227f39995a997f4fd8539bf3cbdc8f47ca8"},
+ {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a955b747d620a50408b7fdf948e04359d6e762ff8a85f5775d907ceced715129"},
+ {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:139a23d1f5d30db2cc6c7fd9c6d6497872a672db22c4ae1910be22d4f4b2068a"},
+ {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ca49e1ab99593438b204e00f3970e7a5f70d045267051dfa6b5f4304fcfa1dbf"},
+ {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:96fc32c16ea6d60d3ca7f63397bff5c75c5a562f7db6dec7d412f7c4d2e78ec0"},
+ {file = "regex-2021.11.10-cp38-cp38-win32.whl", hash = "sha256:0617383e2fe465732af4509e61648b77cbe3aee68b6ac8c0b6fe934db90be5cc"},
+ {file = "regex-2021.11.10-cp38-cp38-win_amd64.whl", hash = "sha256:a3feefd5e95871872673b08636f96b61ebef62971eab044f5124fb4dea39919d"},
+ {file = "regex-2021.11.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7f325be2804246a75a4f45c72d4ce80d2443ab815063cdf70ee8fb2ca59ee1b"},
+ {file = "regex-2021.11.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:537ca6a3586931b16a85ac38c08cc48f10fc870a5b25e51794c74df843e9966d"},
+ {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eef2afb0fd1747f33f1ee3e209bce1ed582d1896b240ccc5e2697e3275f037c7"},
+ {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:432bd15d40ed835a51617521d60d0125867f7b88acf653e4ed994a1f8e4995dc"},
+ {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b43c2b8a330a490daaef5a47ab114935002b13b3f9dc5da56d5322ff218eeadb"},
+ {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:962b9a917dd7ceacbe5cd424556914cb0d636001e393b43dc886ba31d2a1e449"},
+ {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fa8c626d6441e2d04b6ee703ef2d1e17608ad44c7cb75258c09dd42bacdfc64b"},
+ {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3c5fb32cc6077abad3bbf0323067636d93307c9fa93e072771cf9a64d1c0f3ef"},
+ {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cd410a1cbb2d297c67d8521759ab2ee3f1d66206d2e4328502a487589a2cb21b"},
+ {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e6096b0688e6e14af6a1b10eaad86b4ff17935c49aa774eac7c95a57a4e8c296"},
+ {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:529801a0d58809b60b3531ee804d3e3be4b412c94b5d267daa3de7fadef00f49"},
+ {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f594b96fe2e0821d026365f72ac7b4f0b487487fb3d4aaf10dd9d97d88a9737"},
+ {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2409b5c9cef7054dde93a9803156b411b677affc84fca69e908b1cb2c540025d"},
+ {file = "regex-2021.11.10-cp39-cp39-win32.whl", hash = "sha256:3b5df18db1fccd66de15aa59c41e4f853b5df7550723d26aa6cb7f40e5d9da5a"},
+ {file = "regex-2021.11.10-cp39-cp39-win_amd64.whl", hash = "sha256:83ee89483672b11f8952b158640d0c0ff02dc43d9cb1b70c1564b49abe92ce29"},
+ {file = "regex-2021.11.10.tar.gz", hash = "sha256:f341ee2df0999bfdf7a95e448075effe0db212a59387de1a70690e4acb03d4c6"},
+]
+requests = [
+ {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
+ {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
+]
+rope = [
+ {file = "rope-0.16.0-py2-none-any.whl", hash = "sha256:ae1fa2fd56f64f4cc9be46493ce54bed0dd12dee03980c61a4393d89d84029ad"},
+ {file = "rope-0.16.0-py3-none-any.whl", hash = "sha256:52423a7eebb5306a6d63bdc91a7c657db51ac9babfb8341c9a1440831ecf3203"},
+ {file = "rope-0.16.0.tar.gz", hash = "sha256:d2830142c2e046f5fc26a022fe680675b6f48f81c7fc1f03a950706e746e9dfe"},
+]
+six = [
+ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+toml = [
+ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+typed-ast = [
+ {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"},
+ {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"},
+ {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"},
+ {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"},
+ {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"},
+ {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"},
+ {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"},
+ {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"},
+ {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"},
+ {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"},
+ {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"},
+ {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"},
+ {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"},
+ {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"},
+ {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"},
+ {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"},
+ {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"},
+ {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"},
+ {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"},
+ {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"},
+ {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"},
+ {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"},
+ {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"},
+ {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"},
+ {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"},
+ {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"},
+ {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"},
+ {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"},
+ {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"},
+ {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"},
+]
+typing-extensions = [
+ {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"},
+ {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"},
+]
+tzdata = [
+ {file = "tzdata-2021.5-py2.py3-none-any.whl", hash = "sha256:3eee491e22ebfe1e5cfcc97a4137cd70f092ce59144d81f8924a844de05ba8f5"},
+ {file = "tzdata-2021.5.tar.gz", hash = "sha256:68dbe41afd01b867894bbdfd54fa03f468cfa4f0086bfb4adcd8de8f24f3ee21"},
+]
+tzlocal = [
+ {file = "tzlocal-4.1-py3-none-any.whl", hash = "sha256:28ba8d9fcb6c9a782d6e0078b4f6627af1ea26aeaa32b4eab5324abc7df4149f"},
+ {file = "tzlocal-4.1.tar.gz", hash = "sha256:0f28015ac68a5c067210400a9197fc5d36ba9bc3f8eaf1da3cbd59acdfed9e09"},
+]
+urllib3 = [
+ {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"},
+ {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"},
+]
+werkzeug = [
+ {file = "Werkzeug-2.0.2-py3-none-any.whl", hash = "sha256:63d3dc1cf60e7b7e35e97fa9861f7397283b75d765afcaefd993d6046899de8f"},
+ {file = "Werkzeug-2.0.2.tar.gz", hash = "sha256:aa2bb6fc8dee8d6c504c0ac1e7f5f7dc5810a9903e793b6f715a9f015bdadb9a"},
+]
+zipp = [
+ {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"},
+ {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"},
+]
diff --git a/pyproject.toml b/pyproject.toml
index ff7649d..2af639c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"
diff --git a/run.py b/run.py
new file mode 100644
index 0000000..735dfdf
--- /dev/null
+++ b/run.py
@@ -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)
diff --git a/run.sh b/run.sh
new file mode 100755
index 0000000..3ee772c
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+python3 run.py "$@"
+
diff --git a/src/stacosys/db/__init__.py b/src/stacosys/db/__init__.py
deleted file mode 100644
index fd23efe..0000000
--- a/src/stacosys/db/__init__.py
+++ /dev/null
@@ -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
diff --git a/src/stacosys/db/dao.py b/src/stacosys/db/dao.py
deleted file mode 100644
index 7088d87..0000000
--- a/src/stacosys/db/dao.py
+++ /dev/null
@@ -1,83 +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):
- # if published before notification is received
- if comment.notified == None:
- db()(db().comment.id == comment.id).update(notified=datetime.now())
- 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,
- )
diff --git a/src/stacosys/i18n/messages.py b/src/stacosys/i18n/messages.py
deleted file mode 100644
index 0e38b1f..0000000
--- a/src/stacosys/i18n/messages.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import configparser
-import importlib.resources
-
-
-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)
diff --git a/src/stacosys/i18n/messages_en.properties b/src/stacosys/i18n/messages_en.properties
deleted file mode 100644
index 85d02a7..0000000
--- a/src/stacosys/i18n/messages_en.properties
+++ /dev/null
@@ -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.
\ No newline at end of file
diff --git a/src/stacosys/i18n/messages_fr.properties b/src/stacosys/i18n/messages_fr.properties
deleted file mode 100644
index 6486ffe..0000000
--- a/src/stacosys/i18n/messages_fr.properties
+++ /dev/null
@@ -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é
\ No newline at end of file
diff --git a/src/stacosys/interface/__init__.py b/src/stacosys/interface/__init__.py
deleted file mode 100644
index fb1fbd1..0000000
--- a/src/stacosys/interface/__init__.py
+++ /dev/null
@@ -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)
diff --git a/src/stacosys/interface/templates/admin_en.html b/src/stacosys/interface/templates/admin_en.html
deleted file mode 100644
index 59c8f19..0000000
--- a/src/stacosys/interface/templates/admin_en.html
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
-
-
-
-Stacosys Comment Moderation
-
-
-
-
- Comment Moderation
-
-
-
- {% with messages = get_flashed_messages() %}
- {% if messages %}
-
- {% for message in messages %}
- {{ message }}
- {% endfor %}
-
- {% endif %}
- {% endwith %}
-
-
-
- | Date |
- Author |
- Comment |
- Article |
- Actions |
-
-
-
- {% for comment in comments %}
-
- | {{ comment.created }} |
- {{ comment.author_name }} |
- {{ comment.content }} |
- {{ comment.url }} |
-
-
-
- |
-
- {% endfor %}
-
-
-
-
-
-
diff --git a/src/stacosys/interface/templates/admin_fr.html b/src/stacosys/interface/templates/admin_fr.html
deleted file mode 100644
index bfe3221..0000000
--- a/src/stacosys/interface/templates/admin_fr.html
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
-
-
-
-Stacosys
-
-
-
-
-
- {% with messages = get_flashed_messages() %}
- {% if messages %}
-
- {% for message in messages %}
- {{ message }}
- {% endfor %}
-
- {% endif %}
- {% endwith %}
-
-
-
- | Date |
- Auteur |
- Commentaire |
- Article |
- Actions |
-
-
-
- {% for comment in comments %}
-
- | {{ comment.created }} |
- {{ comment.author_name }} |
- {{ comment.content }} |
- {{ comment.url }} |
-
-
-
- |
-
- {% endfor %}
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/stacosys/interface/templates/login_en.html b/src/stacosys/interface/templates/login_en.html
deleted file mode 100644
index 6d81754..0000000
--- a/src/stacosys/interface/templates/login_en.html
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-
-
-
-Stacosys
-
-
-
-
-
- Comment Moderation Login
-
-
- {% with messages = get_flashed_messages() %}
- {% if messages %}
-
- {% for message in messages %}
- {{ message }}
- {% endfor %}
-
- {% endif %}
- {% endwith %}
-
-
-
-
-
diff --git a/src/stacosys/interface/templates/login_fr.html b/src/stacosys/interface/templates/login_fr.html
deleted file mode 100644
index aa385d1..0000000
--- a/src/stacosys/interface/templates/login_fr.html
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-
-
-
-Stacosys
-
-
-
-
-
- Modération des commentaires
-
-
- {% with messages = get_flashed_messages() %}
- {% if messages %}
-
- {% for message in messages %}
- {{ message }}
- {% endfor %}
-
- {% endif %}
- {% endwith %}
-
-
-
-
-
diff --git a/src/stacosys/interface/web/admin.py b/src/stacosys/interface/web/admin.py
deleted file mode 100644
index 9b8c0ef..0000000
--- a/src/stacosys/interface/web/admin.py
+++ /dev/null
@@ -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")
diff --git a/src/stacosys/model/comment.py b/src/stacosys/model/comment.py
deleted file mode 100644
index 999b5a4..0000000
--- a/src/stacosys/model/comment.py
+++ /dev/null
@@ -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 = ""
diff --git a/src/stacosys/run.py b/src/stacosys/run.py
deleted file mode 100644
index 260e2a7..0000000
--- a/src/stacosys/run.py
+++ /dev/null
@@ -1,109 +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))
- try:
- if not mailer.check():
- logger.error("Email configuration not working")
- sys.exit(1)
- except Exception as e:
- logging.error("Failed to check email: %s", e)
- 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)
diff --git a/src/stacosys/service/configuration.py b/src/stacosys/service/configuration.py
deleted file mode 100644
index 8cba173..0000000
--- a/src/stacosys/service/configuration.py
+++ /dev/null
@@ -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)
diff --git a/src/stacosys/service/mail.py b/src/stacosys/service/mail.py
deleted file mode 100644
index be2ded7..0000000
--- a/src/stacosys/service/mail.py
+++ /dev/null
@@ -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
diff --git a/src/stacosys/service/rssfeed.py b/src/stacosys/service/rssfeed.py
deleted file mode 100644
index 5c94d4e..0000000
--- a/src/stacosys/service/rssfeed.py
+++ /dev/null
@@ -1,61 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: UTF-8 -*-
-
-from datetime import datetime, timezone
-
-import markdown
-import PyRSS2Gen
-
-from stacosys.db import dao
-
-RFC_822_FORMAT = "%a, %d %b %Y %H:%M:%S +0000"
-
-
-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=self._to_rfc822(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")
-
- def _to_rfc822(self, dt):
- return dt.replace(tzinfo=timezone.utc).strftime("%a, %d %b %Y %H:%M:%S %z")
diff --git a/stacosys/__init__.py b/stacosys/__init__.py
new file mode 100644
index 0000000..f2dc0e4
--- /dev/null
+++ b/stacosys/__init__.py
@@ -0,0 +1 @@
+__version__ = "2.0"
diff --git a/stacosys/conf/config.py b/stacosys/conf/config.py
new file mode 100644
index 0000000..9793aca
--- /dev/null
+++ b/stacosys/conf/config.py
@@ -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__()
diff --git a/stacosys/core/cron.py b/stacosys/core/cron.py
new file mode 100644
index 0000000..beed2ad
--- /dev/null
+++ b/stacosys/core/cron.py
@@ -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)
diff --git a/stacosys/core/imap.py b/stacosys/core/imap.py
new file mode 100755
index 0000000..b53da1d
--- /dev/null
+++ b/stacosys/core/imap.py
@@ -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 won’t 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(),
+ )
diff --git a/stacosys/core/mailer.py b/stacosys/core/mailer.py
new file mode 100644
index 0000000..c8a2ffd
--- /dev/null
+++ b/stacosys/core/mailer.py
@@ -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)
diff --git a/stacosys/core/rss.py b/stacosys/core/rss.py
new file mode 100644
index 0000000..79366af
--- /dev/null
+++ b/stacosys/core/rss.py
@@ -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")
diff --git a/stacosys/core/templater.py b/stacosys/core/templater.py
new file mode 100644
index 0000000..d3d4564
--- /dev/null
+++ b/stacosys/core/templater.py
@@ -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")
+
diff --git a/stacosys/db/__init__.py b/stacosys/db/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/stacosys/db/dao.py b/stacosys/db/dao.py
new file mode 100644
index 0000000..0a14761
--- /dev/null
+++ b/stacosys/db/dao.py
@@ -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
diff --git a/stacosys/db/database.py b/stacosys/db/database.py
new file mode 100644
index 0000000..5b3af96
--- /dev/null
+++ b/stacosys/db/database.py
@@ -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)
diff --git a/stacosys/interface/__init__.py b/stacosys/interface/__init__.py
new file mode 100644
index 0000000..1fab892
--- /dev/null
+++ b/stacosys/interface/__init__.py
@@ -0,0 +1,6 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+from flask import Flask
+
+app = Flask(__name__)
diff --git a/src/stacosys/interface/api.py b/stacosys/interface/api.py
similarity index 57%
rename from src/stacosys/interface/api.py
rename to stacosys/interface/api.py
index 13dbcff..cab2905 100644
--- a/src/stacosys/interface/api.py
+++ b/stacosys/interface/api.py
@@ -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)})
diff --git a/src/stacosys/interface/form.py b/stacosys/interface/form.py
similarity index 58%
rename from src/stacosys/interface/form.py
rename to stacosys/interface/form.py
index 11d2ca3..085c586 100644
--- a/src/stacosys/interface/form.py
+++ b/stacosys/interface/form.py
@@ -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
+
+
diff --git a/stacosys/interface/scheduler.py b/stacosys/interface/scheduler.py
new file mode 100644
index 0000000..33ea3ef
--- /dev/null
+++ b/stacosys/interface/scheduler.py
@@ -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()
diff --git a/stacosys/model/comment.py b/stacosys/model/comment.py
new file mode 100644
index 0000000..843e8b6
--- /dev/null
+++ b/stacosys/model/comment.py
@@ -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()
diff --git a/stacosys/model/email.py b/stacosys/model/email.py
new file mode 100644
index 0000000..e67fecf
--- /dev/null
+++ b/stacosys/model/email.py
@@ -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
diff --git a/stacosys/templates/en/approve_comment.tpl b/stacosys/templates/en/approve_comment.tpl
new file mode 100644
index 0000000..145ca2c
--- /dev/null
+++ b/stacosys/templates/en/approve_comment.tpl
@@ -0,0 +1,9 @@
+Hi,
+
+The comment should be published soon. It has been approved.
+
+--
+Stacosys
+
+
+{{ original }}
diff --git a/stacosys/templates/en/drop_comment.tpl b/stacosys/templates/en/drop_comment.tpl
new file mode 100644
index 0000000..6aaed72
--- /dev/null
+++ b/stacosys/templates/en/drop_comment.tpl
@@ -0,0 +1,9 @@
+Hi,
+
+The comment will not be published. It has been dropped.
+
+--
+Stacosys
+
+
+{{ original }}
diff --git a/stacosys/templates/en/new_comment.tpl b/stacosys/templates/en/new_comment.tpl
new file mode 100644
index 0000000..490f714
--- /dev/null
+++ b/stacosys/templates/en/new_comment.tpl
@@ -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
diff --git a/stacosys/templates/en/notify_message.tpl b/stacosys/templates/en/notify_message.tpl
new file mode 100644
index 0000000..94a261f
--- /dev/null
+++ b/stacosys/templates/en/notify_message.tpl
@@ -0,0 +1 @@
+New comment
diff --git a/stacosys/templates/en/rss_title_message.tpl b/stacosys/templates/en/rss_title_message.tpl
new file mode 100644
index 0000000..b0b1e30
--- /dev/null
+++ b/stacosys/templates/en/rss_title_message.tpl
@@ -0,0 +1 @@
+{{ site }} : comments
diff --git a/stacosys/templates/fr/approve_comment.tpl b/stacosys/templates/fr/approve_comment.tpl
new file mode 100644
index 0000000..35668d4
--- /dev/null
+++ b/stacosys/templates/fr/approve_comment.tpl
@@ -0,0 +1,9 @@
+Bonjour,
+
+Le commentaire sera bientôt publié. Il a été approuvé.
+
+--
+Stacosys
+
+
+{{ original }}
diff --git a/stacosys/templates/fr/drop_comment.tpl b/stacosys/templates/fr/drop_comment.tpl
new file mode 100644
index 0000000..70e13ed
--- /dev/null
+++ b/stacosys/templates/fr/drop_comment.tpl
@@ -0,0 +1,9 @@
+Bonjour,
+
+Le commentaire ne sera pas publié. Il a été rejeté.
+
+--
+Stacosys
+
+
+{{ original }}
diff --git a/stacosys/templates/fr/new_comment.tpl b/stacosys/templates/fr/new_comment.tpl
new file mode 100644
index 0000000..5671563
--- /dev/null
+++ b/stacosys/templates/fr/new_comment.tpl
@@ -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
diff --git a/stacosys/templates/fr/notify_message.tpl b/stacosys/templates/fr/notify_message.tpl
new file mode 100644
index 0000000..5455f77
--- /dev/null
+++ b/stacosys/templates/fr/notify_message.tpl
@@ -0,0 +1 @@
+Nouveau commentaire
diff --git a/stacosys/templates/fr/rss_title_message.tpl b/stacosys/templates/fr/rss_title_message.tpl
new file mode 100644
index 0000000..db993f6
--- /dev/null
+++ b/stacosys/templates/fr/rss_title_message.tpl
@@ -0,0 +1 @@
+{{ site }} : commentaires
diff --git a/tests-badge.svg b/tests-badge.svg
deleted file mode 100644
index 29110b8..0000000
--- a/tests-badge.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/tests/test_api.py b/tests/test_api.py
index 6e27120..9428f45 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -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'] == []
diff --git a/tests/test_config.py b/tests/test_config.py
index 78a2da4..3bee34b 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -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)
diff --git a/tests/test_db.py b/tests/test_db.py
index 68d6f83..2d99936 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -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 test_dao_published(self):
+
+ # test count published
+ self.assertEqual(0, dao.count_published_comments(""))
+ c1 = dao.create_comment("/post1", "Yax", "", "", "Comment 1")
+ self.assertEqual(0, dao.count_published_comments(""))
+ dao.publish_comment(c1)
+ self.assertEqual(1, dao.count_published_comments(""))
+ c2 = dao.create_comment("/post2", "Yax", "", "", "Comment 2")
+ dao.publish_comment(c2)
+ self.assertEqual(2, dao.count_published_comments(""))
+ c3 = dao.create_comment("/post2", "Yax", "", "", "Comment 3")
+ dao.publish_comment(c3)
+ self.assertEqual(1, dao.count_published_comments("/post1"))
+ self.assertEqual(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")))
+
+ dao.delete_comment(c1)
+ self.assertEqual(0, len(dao.find_published_comments_by_url("/post1")))
+
+ def test_dao_notified(self):
+
+ # test count notified
+ self.assertEqual(0, len(dao.find_not_notified_comments()))
+ c1 = dao.create_comment("/post1", "Yax", "", "", "Comment 1")
+ self.assertEqual(1, len(dao.find_not_notified_comments()))
+ c2 = dao.create_comment("/post2", "Yax", "", "", "Comment 2")
+ self.assertEqual(2, len(dao.find_not_notified_comments()))
+ dao.notify_comment(c1)
+ dao.notify_comment(c2)
+ self.assertEqual(0, len(dao.find_not_notified_comments()))
+ c3 = dao.create_comment("/post2", "Yax", "", "", "Comment 3")
+ self.assertEqual(1, len(dao.find_not_notified_comments()))
+ dao.notify_comment(c3)
+ self.assertEqual(0, len(dao.find_not_notified_comments()))
-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_find_comment_by_id(setup_db):
- assert dao.find_comment_by_id(1) is None
- 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())
- dao.publish_comment(c1)
- assert 1 == dao.count_published_comments("")
- c2 = dao.create_comment("/post2", "Yax", "", "", "Comment 2")
- dao.publish_comment(c2)
- assert 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())
-
- # count published
- assert 1 == dao.count_published_comments("/post1")
- assert 2 == dao.count_published_comments("/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"))
-
-
-def test_dao_notified(setup_db):
- assert 0 == len(dao.find_not_notified_comments())
- c1 = dao.create_comment("/post1", "Yax", "", "", "Comment 1")
- assert 1 == len(dao.find_not_notified_comments())
- c2 = dao.create_comment("/post2", "Yax", "", "", "Comment 2")
- assert 2 == len(dao.find_not_notified_comments())
- dao.notify_comment(c1)
- dao.notify_comment(c2)
- assert 0 == len(dao.find_not_notified_comments())
- c3 = dao.create_comment("/post2", "Yax", "", "", "Comment 3")
- assert 1 == len(dao.find_not_notified_comments())
- dao.notify_comment(c3)
- assert 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()
diff --git a/tests/test_form.py b/tests/test_form.py
index f87a172..94bea7e 100644
--- a/tests/test_form.py
+++ b/tests/test_form.py
@@ -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'})
diff --git a/tests/test_imap.py b/tests/test_imap.py
new file mode 100644
index 0000000..474fe85
--- /dev/null
+++ b/tests/test_imap.py
@@ -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))
diff --git a/tests/test_mail.py b/tests/test_mail.py
deleted file mode 100644
index 69aa307..0000000
--- a/tests/test_mail.py
+++ /dev/null
@@ -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()
diff --git a/tests/test_rssfeed.py b/tests/test_rssfeed.py
deleted file mode 100644
index 5b102fe..0000000
--- a/tests/test_rssfeed.py
+++ /dev/null
@@ -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")
diff --git a/tests/test_stacosys.py b/tests/test_stacosys.py
new file mode 100644
index 0000000..23b2aaa
--- /dev/null
+++ b/tests/test_stacosys.py
@@ -0,0 +1,9 @@
+import unittest
+
+from stacosys import __version__
+
+
+class StacosysTestCase(unittest.TestCase):
+
+ def test_version(self):
+ self.assertEqual("2.0", __version__)
diff --git a/tests/test_templater.py b/tests/test_templater.py
new file mode 100644
index 0000000..117d19d
--- /dev/null
+++ b/tests/test_templater.py
@@ -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)
diff --git a/uv.lock b/uv.lock
deleted file mode 100644
index 154cad1..0000000
--- a/uv.lock
+++ /dev/null
@@ -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 },
-]