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

Modération des commentaires

- -
-
- {% with messages = get_flashed_messages() %} - {% if messages %} -
- {% for message in messages %} -

{{ message }}

- {% endfor %} -
- {% endif %} - {% endwith %} - - - - - - - - - - - - {% for comment in comments %} - - - - - - - - {% endfor %} - -
DateAuteurCommentaireArticleActions
{{ comment.created }}{{ comment.author_name }}{{ comment.content }}{{ comment.url }} -
- - - -
-
- - - -
-
-
- - - \ 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 @@ -tests: 19tests19 \ 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 }, -]