diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 7f0842d..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Docker Image CI -on: - push: - branches: [ master ] -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 }}:$GITHUB_SHA - docker push docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA diff --git a/.gitignore b/.gitignore index b93037e..9d89a5a 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,12 +62,16 @@ db.sqlite db.json node_modules comments.xml -stacosys/bin/ -stacosys/pyvenv.cfg -stacosys/lib64 .vscode/ .pytest_cache/ workspace.code-workspace *.sqlite 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 new file mode 100644 index 0000000..75dae48 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,622 @@ +[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 5a09258..f3a448e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,23 @@ -FROM python:3.9-alpine +FROM python:3.13.1-alpine3.20 -ARG STACOSYS_VERSION=2.0 +ARG STACOSYS_VERSION=3.4 ARG STACOSYS_FILENAME=stacosys-${STACOSYS_VERSION}-py3-none-any.whl -RUN apk update && apk add bash && apk add wget && rm -rf /var/cache/apk/* +RUN apk update && apk add bash && apk add wget + +# Timezone +RUN apk add tzdata +RUN cp /usr/share/zoneinfo/Europe/Paris /etc/localtime +RUN echo "Europe/Paris" > /etc/timezone + +# Clean apk cache +RUN rm -rf /var/cache/apk/* COPY docker/docker-init.sh /usr/local/bin/ RUN chmod +x usr/local/bin/docker-init.sh RUN cd / -#COPY ${STACOSYS_FILENAME} / -RUN wget https://github.com/kianby/stacosys/releases/download/${STACOSYS_VERSION}/${STACOSYS_FILENAME} +COPY dist/${STACOSYS_FILENAME} / RUN python3 -m pip install ${STACOSYS_FILENAME} --target /stacosys RUN rm -f ${STACOSYS_FILENAME} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7b6b402 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +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 feaed5e..6986c76 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ +[![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) + +[![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)]() + ## Stacosys -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. +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. ### Features overview @@ -11,25 +14,24 @@ 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 email notification from Stacosys when a +- Blog administrator receives an e-mail notification from Stacosys when a comment is submitted -- Blog administrator can approve or drop the comment by replying to e-mail +- Blog administrator can approve or drop the comment through a simple web admin interface - Stacosys stores approved comment in its database. -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. +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. -Stacosys is localized (english and french). +Stacosys is more or less localized (english and french). ### Technically speaking, how does it work? -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. - +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. ### Little FAQ *How do you block spammers?* -- Current comment form is basic: no captcha support but a honey pot. +- Current comment form is basic: no captcha support but protected by a honeypot. *Which database is used?* @@ -37,15 +39,22 @@ Stacosys can be hosted on the same server or on a different server than the blog *Which technologies are used?* -- [Python 3.9](https://www.python.org) +- [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 is based on [Poetry](https://python-poetry.org/) but you can also use [published releases on GitHub](https://github.com/kianby/stacosys/releases) or the [Docker image](https://hub.docker.com/repository/docker/kianby/stacosys). +Build and Dependency management relies on [uv](https://docs.astral.sh/uv/) + +Run tests and coverage + + make test + +Build docker image + + make build ### 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 the project and enhance the project if you need more 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 and enhance the project if you need additional features. diff --git a/config.ini b/config.ini index 224b7e6..f6ff236 100755 --- a/config.ini +++ b/config.ini @@ -2,34 +2,29 @@ ; Default configuration [main] lang = fr -db_sqlite_file = db.sqlite -newcomment_polling = 60 +db = sqlite://db.sqlite [site] name = "My blog" -url = http://blog.mydomain.com +proto = https +url = https://blog.mydomain.com admin_email = admin@mydomain.com +redirect = /redirect [http] host = 127.0.0.1 port = 8100 [rss] -proto = https file = comments.xml -[imap] -polling = 120 -host = mail.gandi.net -ssl = false -port = 993 +[smtp] +host = smtp.mail.com +port = 465 login = blog@mydomain.com password = MYPASSWORD -[smtp] -host = mail.gandi.net -starttls = true -ssl = false -port = 587 -login = blog@mydomain.com -password = MYPASSWORD +[web] +username = admin +; SHA-256 hashed password (https://coding.tools/sha256) +password = 8C6976E5B5410415BDE908BD4DEE15DFB167A9C873FC4BB8A81F6F2AB448A918 diff --git a/coverage-badge.svg b/coverage-badge.svg new file mode 100644 index 0000000..dcb99ba --- /dev/null +++ b/coverage-badge.svg @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..43a317c --- /dev/null +++ b/dbmigration/create_empty_db.py @@ -0,0 +1,24 @@ +#!/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 396f576..e4618c8 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/python +#!/usr/bin/env python # -*- coding: UTF-8 -*- import sqlite3 @@ -6,27 +6,30 @@ 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 colum from comment table +# 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 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; @@ -34,4 +37,4 @@ PRAGMA foreign_keys = ON; """ cursor.executescript(script) -connection.close() \ No newline at end of file +connection.close() diff --git a/docker/docker-init.sh b/docker/docker-init.sh index b573f35..7c51f8e 100644 --- a/docker/docker-init.sh +++ b/docker/docker-init.sh @@ -1,4 +1,9 @@ #!/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 deleted file mode 100644 index 1b1827a..0000000 --- a/poetry.lock +++ /dev/null @@ -1,665 +0,0 @@ -[[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.7.0" -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" - -[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 = "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.5.30" -description = "Python package for providing Mozilla's CA Bundle." -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "charset-normalizer" -version = "2.0.3" -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.1" -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 = "flake8" -version = "3.9.2" -description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" - -[package.dependencies] -mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.7.0,<2.8.0" -pyflakes = ">=2.3.0,<2.4.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.1" -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.2" -description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" -optional = false -python-versions = ">=3.5" - -[[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.1" -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.4" -description = "Python implementation of Markdown." -category = "main" -optional = false -python-versions = ">=3.6" - -[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 = "pathspec" -version = "0.8.1" -description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[[package]] -name = "peewee" -version = "3.14.4" -description = "a little orm" -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "profig" -version = "0.5.1" -description = "A configuration library." -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "pycodestyle" -version = "2.7.0" -description = "Python style guide checker" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]] -name = "pyflakes" -version = "2.3.1" -description = "passive checker of Python programs" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]] -name = "pyrss2gen" -version = "1.1" -description = "Generate RSS2 using a Python data structure" -category = "main" -optional = false -python-versions = "*" - -[[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.1" -description = "World timezone definitions, modern and historical" -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "regex" -version = "2021.7.6" -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 = "3.10.0.0" -description = "Backported and Experimental Type Hints for Python 3.5+" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "tzlocal" -version = "2.1" -description = "tzinfo object for the local timezone" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -pytz = "*" - -[[package]] -name = "urllib3" -version = "1.26.6" -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.1" -description = "The comprehensive WSGI web application library." -category = "main" -optional = false -python-versions = ">=3.6" - -[package.extras] -watchdog = ["watchdog"] - -[metadata] -lock-version = "1.1" -python-versions = "^3.9" -content-hash = "8190054ee0a6bf5fccefd841ac71fa6851a4e7d057c5af6fb83ea2364711cb78" - -[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.7.0-py2.py3-none-any.whl", hash = "sha256:c06cc796d5bb9eb3c4f77727f6223476eb67749e7eea074d1587550702a7fbe3"}, - {file = "APScheduler-3.7.0.tar.gz", hash = "sha256:1cab7f2521e107d07127b042155b632b7a1cd5e02c34be5a28ff62f77c900c6a"}, -] -black = [ - {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, -] -certifi = [ - {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, - {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, -] -charset-normalizer = [ - {file = "charset-normalizer-2.0.3.tar.gz", hash = "sha256:c46c3ace2d744cfbdebceaa3c19ae691f53ae621b39fd7570f59d14fb7f2fd12"}, - {file = "charset_normalizer-2.0.3-py3-none-any.whl", hash = "sha256:88fce3fa5b1a84fdcb3f603d889f723d1dd89b26059d0123ca435570e848d5e1"}, -] -click = [ - {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"}, - {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"}, -] -colorama = [ - {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, - {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, -] -flake8 = [ - {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, - {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, -] -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.1-py3-none-any.whl", hash = "sha256:a6209ca15eb63fc9385f38e452704113d679511d9574d09b2cf9183ae7d20dc9"}, - {file = "Flask-2.0.1.tar.gz", hash = "sha256:1c4c257b1892aec1398784c63791cbaa43062f1f7aeb555c4da961b20ee68f55"}, -] -flask-apscheduler = [ - {file = "Flask-APScheduler-1.12.2.tar.gz", hash = "sha256:b9fe174b90d201d8beeba5522b023208f7bb6e2583fc02fea4be4bce5ee8f9e5"}, -] -idna = [ - {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, - {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, -] -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.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, - {file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"}, -] -markdown = [ - {file = "Markdown-3.3.4-py3-none-any.whl", hash = "sha256:96c3ba1261de2f7547b46a00ea8463832c921d3f9d6aba3f255a6f71386db20c"}, - {file = "Markdown-3.3.4.tar.gz", hash = "sha256:31b5b491868dcc87d6c24b7e3d19a0d730d59d3e46f4eea6430a321bed387a49"}, -] -markupsafe = [ - {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-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-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_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-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-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"}, -] -pathspec = [ - {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, - {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, -] -peewee = [ - {file = "peewee-3.14.4.tar.gz", hash = "sha256:9e356b327c2eaec6dd42ecea6f4ddded025793dba906a3d065a0452e726c51a2"}, -] -profig = [ - {file = "profig-0.5.1.tar.gz", hash = "sha256:cb9c094325a93505fc6325d13f3e679b281093223f143a96a6df8ad9c2bfc9a6"}, -] -pycodestyle = [ - {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, - {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, -] -pyflakes = [ - {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, - {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, -] -pyrss2gen = [ - {file = "PyRSS2Gen-1.1.tar.gz", hash = "sha256:7960aed7e998d2482bf58716c316509786f596426f879b05f8d84e98b82c6ee7"}, -] -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.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, - {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, -] -regex = [ - {file = "regex-2021.7.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e6a1e5ca97d411a461041d057348e578dc344ecd2add3555aedba3b408c9f874"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:6afe6a627888c9a6cfbb603d1d017ce204cebd589d66e0703309b8048c3b0854"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ccb3d2190476d00414aab36cca453e4596e8f70a206e2aa8db3d495a109153d2"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:ed693137a9187052fc46eedfafdcb74e09917166362af4cc4fddc3b31560e93d"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:99d8ab206a5270c1002bfcf25c51bf329ca951e5a169f3b43214fdda1f0b5f0d"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:b85ac458354165405c8a84725de7bbd07b00d9f72c31a60ffbf96bb38d3e25fa"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:3f5716923d3d0bfb27048242a6e0f14eecdb2e2a7fac47eda1d055288595f222"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5983c19d0beb6af88cb4d47afb92d96751fb3fa1784d8785b1cdf14c6519407"}, - {file = "regex-2021.7.6-cp36-cp36m-win32.whl", hash = "sha256:c92831dac113a6e0ab28bc98f33781383fe294df1a2c3dfd1e850114da35fd5b"}, - {file = "regex-2021.7.6-cp36-cp36m-win_amd64.whl", hash = "sha256:791aa1b300e5b6e5d597c37c346fb4d66422178566bbb426dd87eaae475053fb"}, - {file = "regex-2021.7.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:59506c6e8bd9306cd8a41511e32d16d5d1194110b8cfe5a11d102d8b63cf945d"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:564a4c8a29435d1f2256ba247a0315325ea63335508ad8ed938a4f14c4116a5d"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:59c00bb8dd8775473cbfb967925ad2c3ecc8886b3b2d0c90a8e2707e06c743f0"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:9a854b916806c7e3b40e6616ac9e85d3cdb7649d9e6590653deb5b341a736cec"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:db2b7df831c3187a37f3bb80ec095f249fa276dbe09abd3d35297fc250385694"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:173bc44ff95bc1e96398c38f3629d86fa72e539c79900283afa895694229fe6a"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:15dddb19823f5147e7517bb12635b3c82e6f2a3a6b696cc3e321522e8b9308ad"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ddeabc7652024803666ea09f32dd1ed40a0579b6fbb2a213eba590683025895"}, - {file = "regex-2021.7.6-cp37-cp37m-win32.whl", hash = "sha256:f080248b3e029d052bf74a897b9d74cfb7643537fbde97fe8225a6467fb559b5"}, - {file = "regex-2021.7.6-cp37-cp37m-win_amd64.whl", hash = "sha256:d8bbce0c96462dbceaa7ac4a7dfbbee92745b801b24bce10a98d2f2b1ea9432f"}, - {file = "regex-2021.7.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:edd1a68f79b89b0c57339bce297ad5d5ffcc6ae7e1afdb10f1947706ed066c9c"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:422dec1e7cbb2efbbe50e3f1de36b82906def93ed48da12d1714cabcd993d7f0"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cbe23b323988a04c3e5b0c387fe3f8f363bf06c0680daf775875d979e376bd26"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:0eb2c6e0fcec5e0f1d3bcc1133556563222a2ffd2211945d7b1480c1b1a42a6f"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:1c78780bf46d620ff4fff40728f98b8afd8b8e35c3efd638c7df67be2d5cddbf"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bc84fb254a875a9f66616ed4538542fb7965db6356f3df571d783f7c8d256edd"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:598c0a79b4b851b922f504f9f39a863d83ebdfff787261a5ed061c21e67dd761"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875c355360d0f8d3d827e462b29ea7682bf52327d500a4f837e934e9e4656068"}, - {file = "regex-2021.7.6-cp38-cp38-win32.whl", hash = "sha256:e586f448df2bbc37dfadccdb7ccd125c62b4348cb90c10840d695592aa1b29e0"}, - {file = "regex-2021.7.6-cp38-cp38-win_amd64.whl", hash = "sha256:2fe5e71e11a54e3355fa272137d521a40aace5d937d08b494bed4529964c19c4"}, - {file = "regex-2021.7.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6110bab7eab6566492618540c70edd4d2a18f40ca1d51d704f1d81c52d245026"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4f64fc59fd5b10557f6cd0937e1597af022ad9b27d454e182485f1db3008f417"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:89e5528803566af4df368df2d6f503c84fbfb8249e6631c7b025fe23e6bd0cde"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2366fe0479ca0e9afa534174faa2beae87847d208d457d200183f28c74eaea59"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f9392a4555f3e4cb45310a65b403d86b589adc773898c25a39184b1ba4db8985"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:2bceeb491b38225b1fee4517107b8491ba54fba77cf22a12e996d96a3c55613d"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:f98dc35ab9a749276f1a4a38ab3e0e2ba1662ce710f6530f5b0a6656f1c32b58"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:319eb2a8d0888fa6f1d9177705f341bc9455a2c8aca130016e52c7fe8d6c37a3"}, - {file = "regex-2021.7.6-cp39-cp39-win32.whl", hash = "sha256:eaf58b9e30e0e546cdc3ac06cf9165a1ca5b3de8221e9df679416ca667972035"}, - {file = "regex-2021.7.6-cp39-cp39-win_amd64.whl", hash = "sha256:4c9c3155fe74269f61e27617529b7f09552fbb12e44b1189cebbdb24294e6e1c"}, - {file = "regex-2021.7.6.tar.gz", hash = "sha256:8394e266005f2d8c6f0bc6780001f7afa3ef81a7a2111fa35058ded6fce79e4d"}, -] -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-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, - {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, - {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, -] -tzlocal = [ - {file = "tzlocal-2.1-py2.py3-none-any.whl", hash = "sha256:e2cb6c6b5b604af38597403e9852872d7f534962ae2954c7f35efcb1ccacf4a4"}, - {file = "tzlocal-2.1.tar.gz", hash = "sha256:643c97c5294aedc737780a49d9df30889321cbe1204eac2c2ec6134035a92e44"}, -] -urllib3 = [ - {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"}, - {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, -] -werkzeug = [ - {file = "Werkzeug-2.0.1-py3-none-any.whl", hash = "sha256:6c1ec500dcdba0baa27600f6a22f6333d8b662d22027ff9f6202e3367413caa8"}, - {file = "Werkzeug-2.0.1.tar.gz", hash = "sha256:1de1db30d010ff1af14a009224ec49ab2329ad2cde454c8a708130642d579c42"}, -] diff --git a/pyproject.toml b/pyproject.toml index b7207eb..ff7649d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,28 +1,43 @@ -[tool.poetry] +[project] name = "stacosys" -version = "2.0b4" +version = "3.4" description = "STAtic COmmenting SYStem" -authors = ["Yax"] readme = "README.md" -include = ["run.py"] +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", +] -[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" -peewee = "^3.14.0" -requests = "^2.25.1" +[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.dev-dependencies] -rope = "^0.16.0" -mypy = "^0.790" -flake8-black = "^0.2.1" -black = "^20.8b1" +[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"] [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" diff --git a/run.py b/run.py deleted file mode 100644 index 854ea82..0000000 --- a/run.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/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.set(ConfigParameter.SITE_TOKEN, hashlib.sha1(conf.get(ConfigParameter.SITE_NAME)).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_TOKEN=conf.get(ConfigParameter.SITE_TOKEN)) - 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 deleted file mode 100755 index 3ee772c..0000000 --- a/run.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -python3 run.py "$@" - diff --git a/src/stacosys/db/__init__.py b/src/stacosys/db/__init__.py new file mode 100644 index 0000000..fd23efe --- /dev/null +++ b/src/stacosys/db/__init__.py @@ -0,0 +1,29 @@ +#!/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 new file mode 100644 index 0000000..7088d87 --- /dev/null +++ b/src/stacosys/db/dao.py @@ -0,0 +1,83 @@ +#!/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 new file mode 100644 index 0000000..0e38b1f --- /dev/null +++ b/src/stacosys/i18n/messages.py @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..85d02a7 --- /dev/null +++ b/src/stacosys/i18n/messages_en.properties @@ -0,0 +1,6 @@ +[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 new file mode 100644 index 0000000..6486ffe --- /dev/null +++ b/src/stacosys/i18n/messages_fr.properties @@ -0,0 +1,6 @@ +[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 new file mode 100644 index 0000000..fb1fbd1 --- /dev/null +++ b/src/stacosys/interface/__init__.py @@ -0,0 +1,42 @@ +#!/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/stacosys/interface/api.py b/src/stacosys/interface/api.py similarity index 57% rename from stacosys/interface/api.py rename to src/stacosys/interface/api.py index cab2905..13dbcff 100644 --- a/stacosys/interface/api.py +++ b/src/stacosys/interface/api.py @@ -6,37 +6,40 @@ import logging from flask import jsonify, request from stacosys.db import dao -from stacosys.interface import app +from stacosys.interface import app, submit_new_comment logger = logging.getLogger(__name__) -@app.route("/ping", methods=["GET"]) +@app.route("/api/ping", methods=["GET"]) def ping(): return "OK" -@app.route("/comments", methods=["GET"]) +@app.route("/api/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): - d = { + comment_dto = { "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: - d["site"] = comment.author_site - logger.debug(d) - comments.append(d) + comment_dto["site"] = comment.author_site + logger.debug(comment_dto) + comments.append(comment_dto) return jsonify({"data": comments}) -@app.route("/comments/count", methods=["GET"]) +@app.route("/api/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/stacosys/interface/form.py b/src/stacosys/interface/form.py similarity index 58% rename from stacosys/interface/form.py rename to src/stacosys/interface/form.py index eb5b826..11d2ca3 100644 --- a/stacosys/interface/form.py +++ b/src/stacosys/interface/form.py @@ -1,26 +1,25 @@ #!/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 +from stacosys.interface import app, submit_new_comment +from stacosys.service.configuration import ConfigParameter logger = logging.getLogger(__name__) @app.route("/newcomment", methods=["POST"]) def new_form_comment(): - data = request.form - logger.info("form data " + str(data)) + logger.info("form data %s", 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", "") @@ -33,21 +32,24 @@ 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 - dao.create_comment(url, author_name, author_site, author_gravatar, message) + comment = dao.create_comment( + url, author_name, author_site, author_gravatar, message + ) - return redirect("/redirect/", code=302) + # send notification e-mail asynchronously + submit_new_comment(comment) + + return redirect(app.config["CONFIG"].get(ConfigParameter.SITE_REDIRECT), code=302) -def check_form_data(d): +def check_form_data(posted_comment): fields = ["url", "message", "site", "remarque", "author", "token", "email"] - filtered = dict(filter(lambda x: x[0] not in fields, d.items())) + filtered = dict(filter(lambda x: x[0] not in fields, posted_comment.items())) return not filtered - - diff --git a/src/stacosys/interface/templates/admin_en.html b/src/stacosys/interface/templates/admin_en.html new file mode 100644 index 0000000..59c8f19 --- /dev/null +++ b/src/stacosys/interface/templates/admin_en.html @@ -0,0 +1,64 @@ + + + + + +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 new file mode 100644 index 0000000..bfe3221 --- /dev/null +++ b/src/stacosys/interface/templates/admin_fr.html @@ -0,0 +1,64 @@ + + + + + +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 new file mode 100644 index 0000000..6d81754 --- /dev/null +++ b/src/stacosys/interface/templates/login_en.html @@ -0,0 +1,42 @@ + + + + + +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 new file mode 100644 index 0000000..aa385d1 --- /dev/null +++ b/src/stacosys/interface/templates/login_fr.html @@ -0,0 +1,42 @@ + + + + + +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 new file mode 100644 index 0000000..9b8c0ef --- /dev/null +++ b/src/stacosys/interface/web/admin.py @@ -0,0 +1,83 @@ +#!/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 new file mode 100644 index 0000000..999b5a4 --- /dev/null +++ b/src/stacosys/model/comment.py @@ -0,0 +1,19 @@ +#!/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 new file mode 100644 index 0000000..260e2a7 --- /dev/null +++ b/src/stacosys/run.py @@ -0,0 +1,109 @@ +#!/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 new file mode 100644 index 0000000..8cba173 --- /dev/null +++ b/src/stacosys/service/configuration.py @@ -0,0 +1,89 @@ +#!/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 new file mode 100644 index 0000000..be2ded7 --- /dev/null +++ b/src/stacosys/service/mail.py @@ -0,0 +1,59 @@ +#!/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 new file mode 100644 index 0000000..5c94d4e --- /dev/null +++ b/src/stacosys/service/rssfeed.py @@ -0,0 +1,61 @@ +#!/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 deleted file mode 100644 index f2dc0e4..0000000 --- a/stacosys/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "2.0" diff --git a/stacosys/conf/config.py b/stacosys/conf/config.py deleted file mode 100644 index e942bb2..0000000 --- a/stacosys/conf/config.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/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" - - -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 deleted file mode 100644 index b4525e3..0000000 --- a/stacosys/core/cron.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/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): - for msg in mailer.fetch(): - # filter stacosys e-mails - m = re.search(REGEX_EMAIL_SUBJECT, msg.subject, re.DOTALL) - if not m: - continue - - 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) - continue - - if not msg.plain_text_content: - logger.warning("ignore empty email") - continue - - _reply_comment_email(lang, mailer, rss, msg, comment_id) - mailer.delete(msg.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 deleted file mode 100755 index cbd7105..0000000 --- a/stacosys/core/imap.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/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 num 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 - - content_disposition = part.get("Content-Disposition", None) - if content_disposition: - # we have attachment - 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() - attachments.append( - Attachment( - filename=email_nonascii_to_uft8(filename), - content=content, - content_type=part.get_content_type(), - ) - ) - else: - try: - content = to_plain_text_content(part) - except Exception: - logging.exception("cannot extract content from mail part") - - parts.append( - Part(content=content, content_type=part.get_content_type()) - ) - - if part.get_content_type() == "text/plain": - plain_text_content = content - - 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_nonascii_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_nonascii_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: - 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", " ") diff --git a/stacosys/core/mailer.py b/stacosys/core/mailer.py deleted file mode 100644 index c8a2ffd..0000000 --- a/stacosys/core/mailer.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/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 deleted file mode 100644 index 79366af..0000000 --- a/stacosys/core/rss.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/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 deleted file mode 100644 index d3d4564..0000000 --- a/stacosys/core/templater.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/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 deleted file mode 100644 index e69de29..0000000 diff --git a/stacosys/db/dao.py b/stacosys/db/dao.py deleted file mode 100644 index 36ebdd4..0000000 --- a/stacosys/db/dao.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/python -# -*- coding: UTF-8 -*- -from datetime import datetime - -from stacosys.model.comment import Comment - - -def find_comment_by_id(id): - return Comment.get_by_id(id) - - -def notify_comment(comment: Comment): - comment.notified = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - comment.save() - - -def publish_comment(comment: Comment): - comment.published = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - 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 deleted file mode 100644 index 5b3af96..0000000 --- a/stacosys/db/database.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/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 deleted file mode 100644 index 1fab892..0000000 --- a/stacosys/interface/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -from flask import Flask - -app = Flask(__name__) diff --git a/stacosys/interface/scheduler.py b/stacosys/interface/scheduler.py deleted file mode 100644 index 33ea3ef..0000000 --- a/stacosys/interface/scheduler.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/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 deleted file mode 100644 index 843e8b6..0000000 --- a/stacosys/model/comment.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/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 deleted file mode 100644 index e67fecf..0000000 --- a/stacosys/model/email.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/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 deleted file mode 100644 index 145ca2c..0000000 --- a/stacosys/templates/en/approve_comment.tpl +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 6aaed72..0000000 --- a/stacosys/templates/en/drop_comment.tpl +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 490f714..0000000 --- a/stacosys/templates/en/new_comment.tpl +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 94a261f..0000000 --- a/stacosys/templates/en/notify_message.tpl +++ /dev/null @@ -1 +0,0 @@ -New comment diff --git a/stacosys/templates/en/rss_title_message.tpl b/stacosys/templates/en/rss_title_message.tpl deleted file mode 100644 index b0b1e30..0000000 --- a/stacosys/templates/en/rss_title_message.tpl +++ /dev/null @@ -1 +0,0 @@ -{{ site }} : comments diff --git a/stacosys/templates/fr/approve_comment.tpl b/stacosys/templates/fr/approve_comment.tpl deleted file mode 100644 index 35668d4..0000000 --- a/stacosys/templates/fr/approve_comment.tpl +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 70e13ed..0000000 --- a/stacosys/templates/fr/drop_comment.tpl +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 5671563..0000000 --- a/stacosys/templates/fr/new_comment.tpl +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 5455f77..0000000 --- a/stacosys/templates/fr/notify_message.tpl +++ /dev/null @@ -1 +0,0 @@ -Nouveau commentaire diff --git a/stacosys/templates/fr/rss_title_message.tpl b/stacosys/templates/fr/rss_title_message.tpl deleted file mode 100644 index db993f6..0000000 --- a/stacosys/templates/fr/rss_title_message.tpl +++ /dev/null @@ -1 +0,0 @@ -{{ site }} : commentaires diff --git a/tests-badge.svg b/tests-badge.svg new file mode 100644 index 0000000..29110b8 --- /dev/null +++ b/tests-badge.svg @@ -0,0 +1 @@ +tests: 19tests19 \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..6e27120 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +# -*- coding: UTF-8 -*- + +import json +import logging + +import pytest + +from stacosys.db import dao, database +from stacosys.interface import api, app + + +def init_test_db(): + c1 = dao.create_comment("/site1", "Bob", "/bob.site", "", "comment 1") + c2 = dao.create_comment("/site2", "Bill", "/bill.site", "", "comment 2") + 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") + init_test_db() + logger.info(f"start interface {api}") + return app.test_client() + + +def test_api_ping(client): + resp = client.get("/api/ping") + assert resp.data == b"OK" + + +def test_api_count_global(client): + resp = client.get("/api/comments/count") + d = json.loads(resp.data) + assert d and d["count"] == 2 + + +def test_api_count_url(client): + resp = client.get("/api/comments/count?url=/site1") + d = json.loads(resp.data) + assert d and d["count"] == 1 + resp = client.get("/api/comments/count?url=/site2") + d = json.loads(resp.data) + assert d and d["count"] == 0 + + +def test_api_comment(client): + resp = client.get("/api/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" + + +def test_api_comment_not_found(client): + resp = client.get("/api/comments?url=/site2") + d = json.loads(resp.data) + assert d and d["data"] == [] diff --git a/tests/test_config.py b/tests/test_config.py index 3bee34b..78a2da4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,48 +1,48 @@ -#!/usr/bin/python +#!/usr/bin/env python # -*- coding: UTF-8 -*- -import unittest +import pytest -from stacosys.conf.config import Config, ConfigParameter +from stacosys.service.configuration import Config, ConfigParameter -EXPECTED_DB_SQLITE_FILE = "db.sqlite" +EXPECTED_DB = "sqlite://db.sqlite" EXPECTED_HTTP_PORT = 8080 -EXPECTED_IMAP_PORT = "5000" -EXPECTED_IMAP_LOGIN = "user" +EXPECTED_LANG = "fr" + +config = Config() -class ConfigTestCase(unittest.TestCase): +@pytest.fixture +def init_config(): + config.put(ConfigParameter.DB, EXPECTED_DB) + config.put(ConfigParameter.HTTP_PORT, EXPECTED_HTTP_PORT) - 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_exists(self): - self.assertTrue(self.conf.exists(ConfigParameter.DB_SQLITE_FILE)) - self.assertFalse(self.conf.exists(ConfigParameter.IMAP_HOST)) +def test_split_key(): + section, param = config._split_key(ConfigParameter.HTTP_PORT) + assert section == "http" and param == "port" - 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_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) +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 diff --git a/tests/test_db.py b/tests/test_db.py index 2d99936..68d6f83 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,55 +1,124 @@ -import unittest +#!/usr/bin/env python +# -*- coding: UTF-8 -*- -from stacosys.db import dao -from stacosys.db import database +import time + +import pytest + +from stacosys.db import dao, database +from stacosys.model.comment import Comment -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())) +@pytest.fixture +def setup_db(): + database.configure("sqlite:memory://db.sqlite") -if __name__ == '__main__': - unittest.main() +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", + ] diff --git a/tests/test_form.py b/tests/test_form.py index f388c6a..f87a172 100644 --- a/tests/test_form.py +++ b/tests/test_form.py @@ -1,22 +1,48 @@ -import unittest +#!/usr/bin/env python +# -*- coding: UTF-8 -*- -from stacosys.interface import form +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 -class FormInterfaceTestCase(unittest.TestCase): - - def test_check_form_data_ok(self): - d = {"url": "/", "message": "", "site": "", "remarque": "", "author": "", "token": "", "email": ""} - self.assertTrue(form.check_form_data(d)) - d = {"url": "/"} - self.assertTrue(form.check_form_data(d)) - d = {} - self.assertTrue(form.check_form_data(d)) - - def test_check_form_data_ko(self): - d = {"url": "/", "message": "", "site": "", "remarque": "", "author": "", "token": "", "email": "", "bonus": ""} - self.assertFalse(form.check_form_data(d)) +@pytest.fixture +def client(): + logger = logging.getLogger(__name__) + database.configure("sqlite:memory://db.sqlite") + logger.info(f"start interface {form}") + app.config["CONFIG"] = Config() + app.config["MAILER"] = Mailer() + app.config["RSS"] = Rss() + return app.test_client() -if __name__ == '__main__': - unittest.main() +def test_new_comment_honeypot(client): + 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" + + +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"} + ) diff --git a/tests/test_mail.py b/tests/test_mail.py new file mode 100644 index 0000000..69aa307 --- /dev/null +++ b/tests/test_mail.py @@ -0,0 +1,14 @@ +#!/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 new file mode 100644 index 0000000..5b102fe --- /dev/null +++ b/tests/test_rssfeed.py @@ -0,0 +1,9 @@ +#!/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 deleted file mode 100644 index 23b2aaa..0000000 --- a/tests/test_stacosys.py +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 117d19d..0000000 --- a/tests/test_templater.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/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 new file mode 100644 index 0000000..154cad1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,567 @@ +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 }, +]