1
0
Fork 0
mirror of https://github.com/Thor77/TeamspeakStats.git synced 2025-05-04 11:15:44 -04:00

Compare commits

..

No commits in common. "master" and "v1.4.2" have entirely different histories.

29 changed files with 349 additions and 1040 deletions

View file

@ -1,23 +0,0 @@
name: publish
on:
push:
tags:
- "*"
jobs:
publish:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Setup poetry
uses: abatilo/actions-poetry@v2
with:
poetry-version: '1.3'
- name: Publish
run: poetry publish --build
env:
POETRY_PYPI_TOKEN_PYPI : ${{ secrets.POETRY_PYPI_TOKEN_PYPI }}

View file

@ -1,42 +0,0 @@
name: test
on:
push: {}
pull_request: {}
workflow_call: {}
jobs:
lint:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Setup poetry
uses: abatilo/actions-poetry@v2
with:
poetry-version: '1.3'
- name: Install dependencies
run: poetry install
- name: Lint
run: poetry run poe lint
test:
strategy:
fail-fast: false
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10"]
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Setup poetry
uses: abatilo/actions-poetry@v2
with:
poetry-version: '1.3'
- name: Install dependencies
run: poetry install
- name: Run tests
run: poetry run poe test

1
.gitignore vendored
View file

@ -1,7 +1,6 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
venv/ venv/
.cache/
build/ build/
dist/ dist/

26
.travis.yml Normal file
View file

@ -0,0 +1,26 @@
language: python
python:
- 2.7
- 3.5
- 3.6
matrix:
include:
- python: 2.7
install:
- pip install flake8
- pip install isort
script:
- flake8 tsstats/**/*.py
- isort -c tsstats/**/*.py
install:
- pip install -r requirements.txt
- pip install -r testing_requirements.txt
- pip install pytest-cov
script: py.test --cov=tsstats tsstats/
after_success:
- pip install coveralls
- coveralls

View file

@ -1,4 +1,4 @@
TeamspeakStats |Build Status| |Build status| |Coverage Status| |PyPI| |Documentation Status| TeamspeakStats |Build Status| |Build status| |Coverage Status| |Code Health| |PyPI| |Documentation Status|
========================================================================================================== ==========================================================================================================
A simple Teamspeak stat-generator - based solely on server-logs A simple Teamspeak stat-generator - based solely on server-logs
@ -28,19 +28,22 @@ Example
:: ::
tsstats -l /var/log/teamspeak3-server/ -o /var/www/tsstats.html tsstats -l /var/log/teamspeak3-server/ts3server*.log -o /var/www/tsstats.html
Parse logs in ``/var/log/teamspeak3-server`` and write output to ``/var/www/tsstats.html``. Parse logs matching ``ts3server*.log`` in ``/var/log/teamspeak3-server``
and write output to ``/var/www/tsstats.html``.
For more details checkout the `documentation <http://teamspeakstats.readthedocs.io/en/latest/>`__! For more details checkout the `documentation <http://teamspeakstats.readthedocs.io/en/latest/>`__!
.. |screenshot| image:: https://raw.githubusercontent.com/Thor77/TeamspeakStats/master/screenshot.png .. |screenshot| image:: https://raw.githubusercontent.com/Thor77/TeamspeakStats/master/screenshot.png
.. |Build Status| image:: https://travis-ci.org/Thor77/TeamspeakStats.svg?branch=master .. |Build Status| image:: https://travis-ci.org/Thor77/TeamspeakStats.svg?branch=master
:target: https://travis-ci.org/Thor77/TeamspeakStats :target: https://travis-ci.org/Thor77/TeamspeakStats
.. |Build status| image:: https://ci.appveyor.com/api/projects/status/u9cx7krwmmevbvl2/branch/master?svg=true .. |Build status| image:: https://ci.appveyor.com/api/projects/status/u9cx7krwmmevbvl2?svg=true
:target: https://ci.appveyor.com/project/Thor77/teamspeakstats :target: https://ci.appveyor.com/project/Thor77/teamspeakstats
.. |Coverage Status| image:: https://coveralls.io/repos/Thor77/TeamspeakStats/badge.svg?branch=master&service=github .. |Coverage Status| image:: https://coveralls.io/repos/Thor77/TeamspeakStats/badge.svg?branch=master&service=github
:target: https://coveralls.io/github/Thor77/TeamspeakStats?branch=master :target: https://coveralls.io/github/Thor77/TeamspeakStats?branch=master
.. |Code Health| image:: https://landscape.io/github/Thor77/TeamspeakStats/master/landscape.svg?style=flat
:target: https://landscape.io/github/Thor77/TeamspeakStats/master
.. |PyPI| image:: https://img.shields.io/pypi/v/tsstats.svg .. |PyPI| image:: https://img.shields.io/pypi/v/tsstats.svg
:target: https://pypi.python.org/pypi/tsstats :target: https://pypi.python.org/pypi/tsstats
.. |Documentation Status| image:: https://readthedocs.org/projects/teamspeakstats/badge/?version=latest .. |Documentation Status| image:: https://readthedocs.org/projects/teamspeakstats/badge/?version=latest

View file

@ -21,7 +21,7 @@ There are unit tests for all parts of the project built with `py.test <https://d
Besides ``py.test`` tests require ``BeautifulSoup`` for template-testing. Besides ``py.test`` tests require ``BeautifulSoup`` for template-testing.
Those requirements are listed in ``testing_requirements.txt``:: Those requirements are listed in ``testing_requirements.txt``::
$ pip install -r requirements-dev.txt $ pip install -r testing_requirement.txt
$ py.test tsstats/tests/ $ py.test tsstats/tests/
Versioning Versioning

512
poetry.lock generated
View file

@ -1,512 +0,0 @@
# This file is automatically @generated by Poetry and should not be changed by hand.
[[package]]
name = "attrs"
version = "22.2.0"
description = "Classes Without Boilerplate"
category = "dev"
optional = false
python-versions = ">=3.6"
files = [
{file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"},
{file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"},
]
[package.extras]
cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"]
dev = ["attrs[docs,tests]"]
docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"]
tests = ["attrs[tests-no-zope]", "zope.interface"]
tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"]
[[package]]
name = "beautifulsoup4"
version = "4.11.1"
description = "Screen-scraping library"
category = "dev"
optional = false
python-versions = ">=3.6.0"
files = [
{file = "beautifulsoup4-4.11.1-py3-none-any.whl", hash = "sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30"},
{file = "beautifulsoup4-4.11.1.tar.gz", hash = "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"},
]
[package.dependencies]
soupsieve = ">1.2"
[package.extras]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "coverage"
version = "7.0.5"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "coverage-7.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2a7f23bbaeb2a87f90f607730b45564076d870f1fb07b9318d0c21f36871932b"},
{file = "coverage-7.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c18d47f314b950dbf24a41787ced1474e01ca816011925976d90a88b27c22b89"},
{file = "coverage-7.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef14d75d86f104f03dea66c13188487151760ef25dd6b2dbd541885185f05f40"},
{file = "coverage-7.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66e50680e888840c0995f2ad766e726ce71ca682e3c5f4eee82272c7671d38a2"},
{file = "coverage-7.0.5-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9fed35ca8c6e946e877893bbac022e8563b94404a605af1d1e6accc7eb73289"},
{file = "coverage-7.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d8d04e755934195bdc1db45ba9e040b8d20d046d04d6d77e71b3b34a8cc002d0"},
{file = "coverage-7.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e109f1c9a3ece676597831874126555997c48f62bddbcace6ed17be3e372de8"},
{file = "coverage-7.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0a1890fca2962c4f1ad16551d660b46ea77291fba2cc21c024cd527b9d9c8809"},
{file = "coverage-7.0.5-cp310-cp310-win32.whl", hash = "sha256:be9fcf32c010da0ba40bf4ee01889d6c737658f4ddff160bd7eb9cac8f094b21"},
{file = "coverage-7.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:cbfcba14a3225b055a28b3199c3d81cd0ab37d2353ffd7f6fd64844cebab31ad"},
{file = "coverage-7.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:30b5fec1d34cc932c1bc04017b538ce16bf84e239378b8f75220478645d11fca"},
{file = "coverage-7.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1caed2367b32cc80a2b7f58a9f46658218a19c6cfe5bc234021966dc3daa01f0"},
{file = "coverage-7.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d254666d29540a72d17cc0175746cfb03d5123db33e67d1020e42dae611dc196"},
{file = "coverage-7.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19245c249aa711d954623d94f23cc94c0fd65865661f20b7781210cb97c471c0"},
{file = "coverage-7.0.5-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b05ed4b35bf6ee790832f68932baf1f00caa32283d66cc4d455c9e9d115aafc"},
{file = "coverage-7.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:29de916ba1099ba2aab76aca101580006adfac5646de9b7c010a0f13867cba45"},
{file = "coverage-7.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e057e74e53db78122a3979f908973e171909a58ac20df05c33998d52e6d35757"},
{file = "coverage-7.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:411d4ff9d041be08fdfc02adf62e89c735b9468f6d8f6427f8a14b6bb0a85095"},
{file = "coverage-7.0.5-cp311-cp311-win32.whl", hash = "sha256:52ab14b9e09ce052237dfe12d6892dd39b0401690856bcfe75d5baba4bfe2831"},
{file = "coverage-7.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:1f66862d3a41674ebd8d1a7b6f5387fe5ce353f8719040a986551a545d7d83ea"},
{file = "coverage-7.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b69522b168a6b64edf0c33ba53eac491c0a8f5cc94fa4337f9c6f4c8f2f5296c"},
{file = "coverage-7.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436e103950d05b7d7f55e39beeb4d5be298ca3e119e0589c0227e6d0b01ee8c7"},
{file = "coverage-7.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c56bec53d6e3154eaff6ea941226e7bd7cc0d99f9b3756c2520fc7a94e6d96"},
{file = "coverage-7.0.5-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a38362528a9115a4e276e65eeabf67dcfaf57698e17ae388599568a78dcb029"},
{file = "coverage-7.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f67472c09a0c7486e27f3275f617c964d25e35727af952869dd496b9b5b7f6a3"},
{file = "coverage-7.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:220e3fa77d14c8a507b2d951e463b57a1f7810a6443a26f9b7591ef39047b1b2"},
{file = "coverage-7.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ecb0f73954892f98611e183f50acdc9e21a4653f294dfbe079da73c6378a6f47"},
{file = "coverage-7.0.5-cp37-cp37m-win32.whl", hash = "sha256:d8f3e2e0a1d6777e58e834fd5a04657f66affa615dae61dd67c35d1568c38882"},
{file = "coverage-7.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9e662e6fc4f513b79da5d10a23edd2b87685815b337b1a30cd11307a6679148d"},
{file = "coverage-7.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:790e4433962c9f454e213b21b0fd4b42310ade9c077e8edcb5113db0818450cb"},
{file = "coverage-7.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:49640bda9bda35b057b0e65b7c43ba706fa2335c9a9896652aebe0fa399e80e6"},
{file = "coverage-7.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d66187792bfe56f8c18ba986a0e4ae44856b1c645336bd2c776e3386da91e1dd"},
{file = "coverage-7.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:276f4cd0001cd83b00817c8db76730938b1ee40f4993b6a905f40a7278103b3a"},
{file = "coverage-7.0.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95304068686545aa368b35dfda1cdfbbdbe2f6fe43de4a2e9baa8ebd71be46e2"},
{file = "coverage-7.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:17e01dd8666c445025c29684d4aabf5a90dc6ef1ab25328aa52bedaa95b65ad7"},
{file = "coverage-7.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea76dbcad0b7b0deb265d8c36e0801abcddf6cc1395940a24e3595288b405ca0"},
{file = "coverage-7.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:50a6adc2be8edd7ee67d1abc3cd20678987c7b9d79cd265de55941e3d0d56499"},
{file = "coverage-7.0.5-cp38-cp38-win32.whl", hash = "sha256:e4ce984133b888cc3a46867c8b4372c7dee9cee300335e2925e197bcd45b9e16"},
{file = "coverage-7.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:4a950f83fd3f9bca23b77442f3a2b2ea4ac900944d8af9993743774c4fdc57af"},
{file = "coverage-7.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c2155943896ac78b9b0fd910fb381186d0c345911f5333ee46ac44c8f0e43ab"},
{file = "coverage-7.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54f7e9705e14b2c9f6abdeb127c390f679f6dbe64ba732788d3015f7f76ef637"},
{file = "coverage-7.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ee30375b409d9a7ea0f30c50645d436b6f5dfee254edffd27e45a980ad2c7f4"},
{file = "coverage-7.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b78729038abea6a5df0d2708dce21e82073463b2d79d10884d7d591e0f385ded"},
{file = "coverage-7.0.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13250b1f0bd023e0c9f11838bdeb60214dd5b6aaf8e8d2f110c7e232a1bff83b"},
{file = "coverage-7.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2c407b1950b2d2ffa091f4e225ca19a66a9bd81222f27c56bd12658fc5ca1209"},
{file = "coverage-7.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c76a3075e96b9c9ff00df8b5f7f560f5634dffd1658bafb79eb2682867e94f78"},
{file = "coverage-7.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f26648e1b3b03b6022b48a9b910d0ae209e2d51f50441db5dce5b530fad6d9b1"},
{file = "coverage-7.0.5-cp39-cp39-win32.whl", hash = "sha256:ba3027deb7abf02859aca49c865ece538aee56dcb4871b4cced23ba4d5088904"},
{file = "coverage-7.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:949844af60ee96a376aac1ded2a27e134b8c8d35cc006a52903fc06c24a3296f"},
{file = "coverage-7.0.5-pp37.pp38.pp39-none-any.whl", hash = "sha256:b9727ac4f5cf2cbf87880a63870b5b9730a8ae3a4a360241a0fdaa2f71240ff0"},
{file = "coverage-7.0.5.tar.gz", hash = "sha256:051afcbd6d2ac39298d62d340f94dbb6a1f31de06dfaf6fcef7b759dd3860c45"},
]
[package.dependencies]
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
toml = ["tomli"]
[[package]]
name = "exceptiongroup"
version = "1.1.0"
description = "Backport of PEP 654 (exception groups)"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"},
{file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"},
]
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "iniconfig"
version = "2.0.0"
description = "brain-dead simple config-ini parsing"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
[[package]]
name = "jinja2"
version = "3.1.2"
description = "A very fast and expressive template engine."
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
{file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
]
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "markupsafe"
version = "2.1.1"
description = "Safely add untrusted strings to HTML/XML markup."
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"},
{file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"},
{file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"},
{file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"},
{file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"},
{file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"},
{file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"},
{file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"},
{file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"},
{file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"},
{file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"},
{file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"},
{file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"},
{file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"},
{file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"},
{file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"},
{file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"},
{file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"},
{file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"},
{file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"},
{file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"},
{file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"},
{file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"},
{file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"},
{file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"},
{file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"},
{file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"},
{file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"},
{file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"},
{file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"},
{file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"},
{file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"},
{file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"},
{file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"},
{file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"},
{file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"},
{file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"},
{file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"},
{file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"},
{file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"},
]
[[package]]
name = "mccabe"
version = "0.7.0"
description = "McCabe checker, plugin for flake8"
category = "dev"
optional = false
python-versions = ">=3.6"
files = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
]
[[package]]
name = "packaging"
version = "23.0"
description = "Core utilities for Python packages"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"},
{file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"},
]
[[package]]
name = "pastel"
version = "0.2.1"
description = "Bring colors to your terminal."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
{file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"},
{file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"},
]
[[package]]
name = "pendulum"
version = "2.1.2"
description = "Python datetimes made easy"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
files = [
{file = "pendulum-2.1.2-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:b6c352f4bd32dff1ea7066bd31ad0f71f8d8100b9ff709fb343f3b86cee43efe"},
{file = "pendulum-2.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:318f72f62e8e23cd6660dbafe1e346950281a9aed144b5c596b2ddabc1d19739"},
{file = "pendulum-2.1.2-cp35-cp35m-macosx_10_15_x86_64.whl", hash = "sha256:0731f0c661a3cb779d398803655494893c9f581f6488048b3fb629c2342b5394"},
{file = "pendulum-2.1.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:3481fad1dc3f6f6738bd575a951d3c15d4b4ce7c82dce37cf8ac1483fde6e8b0"},
{file = "pendulum-2.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9702069c694306297ed362ce7e3c1ef8404ac8ede39f9b28b7c1a7ad8c3959e3"},
{file = "pendulum-2.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:fb53ffa0085002ddd43b6ca61a7b34f2d4d7c3ed66f931fe599e1a531b42af9b"},
{file = "pendulum-2.1.2-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:c501749fdd3d6f9e726086bf0cd4437281ed47e7bca132ddb522f86a1645d360"},
{file = "pendulum-2.1.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c807a578a532eeb226150d5006f156632df2cc8c5693d778324b43ff8c515dd0"},
{file = "pendulum-2.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2d1619a721df661e506eff8db8614016f0720ac171fe80dda1333ee44e684087"},
{file = "pendulum-2.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f888f2d2909a414680a29ae74d0592758f2b9fcdee3549887779cd4055e975db"},
{file = "pendulum-2.1.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:e95d329384717c7bf627bf27e204bc3b15c8238fa8d9d9781d93712776c14002"},
{file = "pendulum-2.1.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4c9c689747f39d0d02a9f94fcee737b34a5773803a64a5fdb046ee9cac7442c5"},
{file = "pendulum-2.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1245cd0075a3c6d889f581f6325dd8404aca5884dea7223a5566c38aab94642b"},
{file = "pendulum-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:db0a40d8bcd27b4fb46676e8eb3c732c67a5a5e6bfab8927028224fbced0b40b"},
{file = "pendulum-2.1.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f5e236e7730cab1644e1b87aca3d2ff3e375a608542e90fe25685dae46310116"},
{file = "pendulum-2.1.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:de42ea3e2943171a9e95141f2eecf972480636e8e484ccffaf1e833929e9e052"},
{file = "pendulum-2.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7c5ec650cb4bec4c63a89a0242cc8c3cebcec92fcfe937c417ba18277d8560be"},
{file = "pendulum-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:33fb61601083f3eb1d15edeb45274f73c63b3c44a8524703dc143f4212bf3269"},
{file = "pendulum-2.1.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:29c40a6f2942376185728c9a0347d7c0f07905638c83007e1d262781f1e6953a"},
{file = "pendulum-2.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:94b1fc947bfe38579b28e1cccb36f7e28a15e841f30384b5ad6c5e31055c85d7"},
{file = "pendulum-2.1.2.tar.gz", hash = "sha256:b06a0ca1bfe41c990bbf0c029f0b6501a7f2ec4e38bfec730712015e8860f207"},
]
[package.dependencies]
python-dateutil = ">=2.6,<3.0"
pytzdata = ">=2020.1"
[[package]]
name = "pluggy"
version = "1.0.0"
description = "plugin and hook calling mechanisms for python"
category = "dev"
optional = false
python-versions = ">=3.6"
files = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "poethepoet"
version = "0.18.0"
description = "A task runner that works well with poetry."
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "poethepoet-0.18.0-py3-none-any.whl", hash = "sha256:62ba57982cc8303356e1535e047abb38e29b6fb8c8455c5920c4e678c24241bc"},
{file = "poethepoet-0.18.0.tar.gz", hash = "sha256:ee2c8a71ac07dea7e415ab9790308f3425ff4423385f159b0b0b4b8f7eba8b84"},
]
[package.dependencies]
pastel = ">=0.2.1,<0.3.0"
tomli = ">=1.2.2"
[package.extras]
poetry-plugin = ["poetry (>=1.0,<2.0)"]
[[package]]
name = "pycodestyle"
version = "2.10.0"
description = "Python style guide checker"
category = "dev"
optional = false
python-versions = ">=3.6"
files = [
{file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"},
{file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"},
]
[[package]]
name = "pydocstyle"
version = "6.2.3"
description = "Python docstring style checker"
category = "dev"
optional = false
python-versions = ">=3.6"
files = [
{file = "pydocstyle-6.2.3-py3-none-any.whl", hash = "sha256:a04ed1e6fe0be0970eddbb1681a7ab59b11eb92729fdb4b9b24f0eb11a25629e"},
{file = "pydocstyle-6.2.3.tar.gz", hash = "sha256:d867acad25e48471f2ad8a40ef9813125e954ad675202245ca836cb6e28b2297"},
]
[package.dependencies]
snowballstemmer = ">=2.2.0"
[package.extras]
toml = ["tomli (>=1.2.3)"]
[[package]]
name = "pyflakes"
version = "3.0.1"
description = "passive checker of Python programs"
category = "dev"
optional = false
python-versions = ">=3.6"
files = [
{file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"},
{file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"},
]
[[package]]
name = "pylama"
version = "8.4.1"
description = "Code audit tool for python"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "pylama-8.4.1-py3-none-any.whl", hash = "sha256:5bbdbf5b620aba7206d688ed9fc917ecd3d73e15ec1a89647037a09fa3a86e60"},
{file = "pylama-8.4.1.tar.gz", hash = "sha256:2d4f7aecfb5b7466216d48610c7d6bad1c3990c29cdd392ad08259b161e486f6"},
]
[package.dependencies]
mccabe = ">=0.7.0"
pycodestyle = ">=2.9.1"
pydocstyle = ">=6.1.1"
pyflakes = ">=2.5.0"
[package.extras]
all = ["eradicate", "mypy", "pylint", "radon", "vulture"]
eradicate = ["eradicate"]
mypy = ["mypy"]
pylint = ["pylint"]
radon = ["radon"]
tests = ["eradicate (>=2.0.0)", "mypy", "pylama-quotes", "pylint (>=2.11.1)", "pytest (>=7.1.2)", "pytest-mypy", "radon (>=5.1.0)", "toml", "types-setuptools", "types-toml", "vulture"]
toml = ["toml (>=0.10.2)"]
vulture = ["vulture"]
[[package]]
name = "pytest"
version = "7.2.1"
description = "pytest: simple powerful testing with Python"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"},
{file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"},
]
[package.dependencies]
attrs = ">=19.2.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
[package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
name = "pytest-cov"
version = "4.0.0"
description = "Pytest plugin for measuring coverage."
category = "dev"
optional = false
python-versions = ">=3.6"
files = [
{file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"},
{file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"},
]
[package.dependencies]
coverage = {version = ">=5.2.1", extras = ["toml"]}
pytest = ">=4.6"
[package.extras]
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
[[package]]
name = "python-dateutil"
version = "2.8.2"
description = "Extensions to the standard Python datetime module"
category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
{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"},
]
[package.dependencies]
six = ">=1.5"
[[package]]
name = "pytzdata"
version = "2020.1"
description = "The Olson timezone database for Python."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
{file = "pytzdata-2020.1-py2.py3-none-any.whl", hash = "sha256:e1e14750bcf95016381e4d472bad004eef710f2d6417240904070b3d6654485f"},
{file = "pytzdata-2020.1.tar.gz", hash = "sha256:3efa13b335a00a8de1d345ae41ec78dd11c9f8807f522d39850f2dd828681540"},
]
[[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.*"
files = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
[[package]]
name = "snowballstemmer"
version = "2.2.0"
description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
category = "dev"
optional = false
python-versions = "*"
files = [
{file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
{file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
]
[[package]]
name = "soupsieve"
version = "2.3.2.post1"
description = "A modern CSS selector implementation for Beautiful Soup."
category = "dev"
optional = false
python-versions = ">=3.6"
files = [
{file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"},
{file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"},
]
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
category = "dev"
optional = false
python-versions = ">=3.7"
files = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "f60d42d4c06c5c2d3a0bcc61f921cb5ee329f4b033d36a95748725268243b01d"

View file

@ -1,31 +0,0 @@
[tool.poetry]
name = "tsstats"
version = "2.1.0"
description = "A simple Teamspeak stats generator"
authors = ["Thor77 <thor77@thor77.org>"]
license = "MIT"
readme = "README.rst"
include = ["tsstats/templates/*.jinja2"]
[tool.poetry.dependencies]
python = "^3.10"
jinja2 = "^3.1.2"
pendulum = "^2.1.2"
[tool.poetry.group.dev.dependencies]
pytest = "^7.2.0"
beautifulsoup4 = "^4.11.1"
pylama = "^8.4.1"
poethepoet = "^0.18.0"
pytest-cov = "^4.0.0"
[tool.poetry.scripts]
tsstats = 'tsstats.__main__:cli'
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poe.tasks]
test = "pytest --cov=tsstats tsstats"
lint = "pylama tsstats"

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
Jinja2>=2.8

24
setup.py Normal file
View file

@ -0,0 +1,24 @@
from setuptools import setup
setup(
name='tsstats',
version='1.4.2',
author='Thor77',
author_email='thor77@thor77.org',
description='A simple Teamspeak stats-generator',
long_description=open('README.rst').read(),
keywords='ts3 teamspeak teamspeak3 tsstats teamspeakstats',
url='https://github.com/Thor77/TeamspeakStats',
packages=['tsstats'],
entry_points={
'console_scripts': [
'tsstats = tsstats.__main__:cli'
]
},
package_data={
'tsstats': ['templates/*.jinja2']
},
install_requires=[
'Jinja2>=2.8'
],
)

3
testing_requirements.txt Normal file
View file

@ -0,0 +1,3 @@
pytest>=2.9.1
pyflakes>=1.2.2
BeautifulSoup4>=4.4.1

View file

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import argparse import argparse
import json import json
import logging import logging
@ -63,11 +64,6 @@ def cli():
'-otth', '--onlinetimethreshold', '-otth', '--onlinetimethreshold',
type=int, help='threshold for displaying onlinetime (in seconds)' type=int, help='threshold for displaying onlinetime (in seconds)'
) )
parser.add_argument(
'-lsa', '--lastseenabsolute',
help='render last seen timestamp absolute (instead of relative)',
action='store_false', dest='lastseenrelative'
)
options = parser.parse_args() options = parser.parse_args()
if 'config' in options: if 'config' in options:
configuration = config.load(options.config) configuration = config.load(options.config)
@ -119,11 +115,7 @@ def main(configuration):
template=configuration.get('General', 'template'), template=configuration.get('General', 'template'),
datetime_fmt=configuration.get('General', 'datetimeformat'), datetime_fmt=configuration.get('General', 'datetimeformat'),
onlinetime_threshold=int(configuration.get( onlinetime_threshold=int(configuration.get(
'General', 'onlinetimethreshold' 'General', 'onlinetimethreshold'))
)),
lastseen_relative=configuration.getboolean(
'General', 'lastseenrelative'
)
) )
logger.info('Finished after %s seconds', time() - start_time) logger.info('Finished after %s seconds', time() - start_time)

View file

@ -1,7 +1,10 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import datetime import datetime
import logging import logging
from collections.abc import MutableMapping from collections import MutableMapping
from tsstats.exceptions import InvalidLog
logger = logging.getLogger('tsstats') logger = logging.getLogger('tsstats')
@ -22,29 +25,6 @@ class Clients(MutableMapping):
self.store = dict() self.store = dict()
self.update(dict(*args, **kwargs)) self.update(dict(*args, **kwargs))
def apply_events(self, events):
'''
Apply events to this Client-collection
:param events: list of events to apply
:type events: list
'''
for event in events:
# find corresponding client
client = self.setdefault(
event.identifier,
Client(self.ident_map.get(event.identifier, event.identifier))
)
if event.action == 'set_nick':
client.nick = event.arg
continue
if event.arg_is_client:
# if arg is client, replace identifier with Client-obj
event = event._replace(
arg=self.setdefault(event.arg, Client(event.arg))
)
client.__getattribute__(event.action)(event.arg)
def __add__(self, client): def __add__(self, client):
''' '''
Add a Client to the collection Add a Client to the collection
@ -52,15 +32,14 @@ class Clients(MutableMapping):
:param client: Client to add to the collection :param client: Client to add to the collection
:type id_or_uid: Client :type id_or_uid: Client
''' '''
identifier = client.identifier self.store[client.identifier] = client
self.store[self.ident_map.get(identifier, identifier)] = client
return self return self
def __iter__(self): def __iter__(self):
''' '''
Yield all Client-objects from the collection Yield all Client-objects from the collection
''' '''
return iter(self.store.keys()) return iter(self.store.values())
def __getitem__(self, key): def __getitem__(self, key):
return self.store[self.ident_map.get(key, key)] return self.store[self.ident_map.get(key, key)]
@ -74,9 +53,6 @@ class Clients(MutableMapping):
def __setitem__(self, key, value): def __setitem__(self, key, value):
self.store[self.ident_map.get(key, key)] = value self.store[self.ident_map.get(key, key)] = value
def __str__(self):
return str(list(map(str, self)))
class Client(object): class Client(object):
''' '''
@ -92,7 +68,7 @@ class Client(object):
''' '''
# public # public
self.identifier = identifier self.identifier = identifier
self._nick = nick self.nick = nick
self.nick_history = set() self.nick_history = set()
self.connected = 0 self.connected = 0
self.onlinetime = datetime.timedelta() self.onlinetime = datetime.timedelta()
@ -104,18 +80,6 @@ class Client(object):
# private # private
self._last_connect = 0 self._last_connect = 0
@property
def nick(self):
return self._nick
@nick.setter
def nick(self, new_nick):
if self._nick and new_nick != self._nick:
# add old nick to history
self.nick_history.add(self._nick)
# set new nick
self._nick = new_nick
def connect(self, timestamp): def connect(self, timestamp):
''' '''
Connect client at `timestamp` Connect client at `timestamp`
@ -137,7 +101,7 @@ class Client(object):
logger.debug('[%s] DISCONNECT %s', timestamp, self) logger.debug('[%s] DISCONNECT %s', timestamp, self)
if not self.connected: if not self.connected:
logger.debug('^ disconnect before connect') logger.debug('^ disconnect before connect')
return raise InvalidLog('disconnect before connect!')
self.connected -= 1 self.connected -= 1
session_time = timestamp - self._last_connect session_time = timestamp - self._last_connect
logger.debug('Session lasted %s', session_time) logger.debug('Session lasted %s', session_time)
@ -167,7 +131,4 @@ class Client(object):
self.bans += 1 self.bans += 1
def __str__(self): def __str__(self):
return u'<{}, {}>'.format(self.identifier, self.nick) return '<{},{}>'.format(self.identifier, self.nick)
def __repr__(self):
return self.__str__()

View file

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
try: try:
from configparser import RawConfigParser from configparser import RawConfigParser
except ImportError: except ImportError:
@ -19,8 +20,7 @@ DEFAULT_CONFIG = {
'onlinedc': True, 'onlinedc': True,
'template': 'index.jinja2', 'template': 'index.jinja2',
'datetimeformat': '%x %X %Z', 'datetimeformat': '%x %X %Z',
'onlinetimethreshold': -1, 'onlinetimethreshold': -1
'lastseenrelative': True
} }
} }

View file

@ -1,34 +0,0 @@
# -*- coding: utf-8 -*-
from collections import namedtuple
Event = namedtuple(
'Event', ['timestamp', 'identifier', 'action', 'arg', 'arg_is_client']
)
def nick(timestamp, identifier, nick):
return Event(timestamp, identifier, 'set_nick', nick, arg_is_client=False)
def connect(timestamp, identifier):
return Event(
timestamp, identifier, 'connect', arg=timestamp, arg_is_client=False
)
def disconnect(timestamp, identifier):
return Event(
timestamp, identifier, 'disconnect', arg=timestamp, arg_is_client=False
)
def kick(timestamp, identifier, target_identifier):
return Event(
timestamp, identifier, 'kick', target_identifier, arg_is_client=True
)
def ban(timestamp, identifier, target_identifier):
return Event(
timestamp, identifier, 'ban', target_identifier, arg_is_client=True
)

View file

@ -1,5 +1,15 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
class InvalidConfiguration(Exception): class InvalidConfiguration(Exception):
''' '''
The configuration is invalid (either config-file or cli-args) The configuration is invalid (either config-file or cli-args)
''' '''
class InvalidLog(Exception):
'''
Something impossible appeared at the logs,
for example a disconnect before a connect
'''
pass

View file

@ -1,40 +1,75 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import itertools
import logging import logging
import re import re
from codecs import open from codecs import open
from collections import namedtuple from collections import namedtuple
from datetime import datetime
from glob import glob from glob import glob
from os.path import basename from os.path import basename
from time import time
import pendulum from tsstats.client import Client, Clients
from tsstats.utils import tz_aware_datime
from tsstats import events
from tsstats.client import Clients
re_log_filename = re.compile(r'ts3server_(?P<date>\d{4}-\d\d-\d\d)' re_log_filename = re.compile(r'ts3server_(?P<date>\d{4}-\d\d-\d\d)'
r'__(?P<time>\d\d_\d\d_\d\d.\d+)_(?P<sid>\d).log') '__(?P<time>\d\d_\d\d_\d\d.\d+)_(?P<sid>\d).log')
re_log_entry = re.compile(r'(?P<timestamp>\d{4}-\d\d-\d\d\ \d\d:\d\d:\d\d.\d+)' re_log_entry = re.compile('(?P<timestamp>\d{4}-\d\d-\d\d\ \d\d:\d\d:\d\d.\d+)'
r'\|\ *(?P<level>\w+)\ *\|\ *(?P<component>\w+)\ *' '\|\ *(?P<level>\w+)\ *\|\ *(?P<component>\w+)\ *'
r'\|\ *(?P<sid>\d+)\ *\|\ *(?P<message>.*)') '\|\ *(?P<sid>\d+)\ *\|\ *(?P<message>.*)')
re_dis_connect = re.compile( re_dis_connect = re.compile(
r"client (?P<action>(dis)?connected) '(?P<nick>.*)'\(id:(?P<clid>\d+)\)") r"client (?P<action>(dis)?connected) '(?P<nick>.*)'\(id:(?P<clid>\d+)\)")
re_disconnect_invoker = re.compile( re_disconnect_invoker = re.compile(
r'invokername=(.*)\ invokeruid=(.*)\ reasonmsg' r'invokername=(.*)\ invokeruid=(.*)\ reasonmsg'
) )
log_timestamp_format = '%Y-%m-%d %H:%M:%S.%f'
TimedLog = namedtuple('TimedLog', ['path', 'timestamp']) TimedLog = namedtuple('TimedLog', ['path', 'timestamp'])
Server = namedtuple('Server', ['sid', 'clients']) Server = namedtuple('Server', ['sid', 'clients'])
logger = logging.getLogger('tsstats') logger = logging.getLogger('tsstats')
def parse_logs(log_glob, ident_map=None, online_dc=True, *args, **kwargs):
'''
parse logs from `log_glob`
:param log_glob: path to server-logs (supports globbing)
:param ident_map: identmap used for Client-initializations
:type log_glob: str
:type ident_map: dict
:return: clients bundled by virtual-server
:rtype: tsstats.log.Server
'''
server = []
for virtualserver_id, logs in\
_bundle_logs(glob(log_glob)).items():
clients = Clients(ident_map)
# keep last log out of the iteration for now
for log in logs[:-1]:
# don't reconnect connected clients for all logs except last one
# because that would lead to insane onlinetimes
_parse_details(log.path, clients=clients, online_dc=False,
*args, **kwargs)
# now parse details of last log with correct online_dc set
_parse_details(logs[-1].path, clients=clients, online_dc=online_dc,
*args, **kwargs)
if len(clients) >= 1:
server.append(Server(virtualserver_id, clients))
return server
def _bundle_logs(logs): def _bundle_logs(logs):
''' '''
Bundle `logs` by virtualserver-id bundle `logs` by virtualserver-id
and sort by timestamp from filename (if exists) and sort by timestamp from filename (if exists)
:param logs: list of paths to logfiles :param logs: list of paths to logfiles
:type logs: list :type logs: list
:return: `logs` bundled by virtualserver-id and sorted by timestamp :return: `logs` bundled by virtualserver-id and sorted by timestamp
@ -46,9 +81,9 @@ def _bundle_logs(logs):
match = re_log_filename.match(basename(log)) match = re_log_filename.match(basename(log))
if match: if match:
match = match.groupdict() match = match.groupdict()
timestamp = pendulum.parse('{0} {1}'.format( timestamp = datetime.strptime('{0} {1}'.format(
match['date'], match['time'].replace('_', ':')) match['date'], match['time'].replace('_', ':')),
) log_timestamp_format)
tl = TimedLog(log, timestamp) tl = TimedLog(log, timestamp)
sid = match['sid'] sid = match['sid']
if sid in vserver_logfiles: if sid in vserver_logfiles:
@ -70,117 +105,84 @@ def _bundle_logs(logs):
return vserver_logfiles return vserver_logfiles
def _parse_line(line): def _parse_details(log_path, ident_map=None, clients=None, online_dc=True):
''' '''
Parse events from a single line extract details from log-files
:param line: line to parse events from detailed parsing is done here: onlinetime, kicks, pkicks, bans, pbans
:type line: str
:return: parsed events :param log_path: path to log-file
:rtype list :param ident_map: :doc:`identmap`
''' :param clients: clients-object to add parsing-results to
parsed_events = [] :param online_cd: disconnect online clients after parsing
match = re_log_entry.match(line)
if not match:
logger.debug('No match: "%s"', line)
return []
match = match.groupdict()
logdatetime = pendulum.parse(match['timestamp'])
message = match['message']
if message.startswith('client'):
match = re_dis_connect.match(message)
if not match:
logger.debug('Unsupported client action: "%s"', message)
return []
nick, clid = match.group('nick'), match.group('clid')
parsed_events.append(events.nick(logdatetime, clid, nick)) :type log_path: str
action = match.group('action')
if action == 'connected':
parsed_events.append(events.connect(logdatetime, clid))
elif action == 'disconnected':
parsed_events.append(events.disconnect(logdatetime, clid))
if 'invokeruid' in message:
re_disconnect_data = re_disconnect_invoker.findall(
message)
invokernick, invokeruid = re_disconnect_data[0]
parsed_events.append(
events.nick(logdatetime, invokeruid, invokernick)
)
if 'bantime' in message:
parsed_events.append(
events.ban(logdatetime, invokeruid, clid)
)
else:
parsed_events.append(
events.kick(logdatetime, invokeruid, clid)
)
return parsed_events
def parse_logs(log_glob, ident_map=None, online_dc=True):
'''
Parse logs from `log_glob`
:param log_glob: path to server-logs (supports globbing)
:param ident_map: identmap used for Client-initializations
:type log_glob: str
:type ident_map: dict :type ident_map: dict
:type clients: tsstats.client.Clients
:type online_cd: bool
:return: list of servers :return: parsed clients
:rtype: [tsstats.log.Server] :rtype: tsstats.client.Clients
''' '''
for virtualserver_id, logs in _bundle_logs(glob(log_glob)).items(): start_time = time()
if clients is None:
clients = Clients(ident_map) clients = Clients(ident_map)
for index, log in enumerate(logs): log_file = open(log_path, encoding='utf-8')
with open(log.path, encoding='utf-8') as f: # process lines
logger.debug('Started parsing of %s', f.name) logger.debug('Started parsing of %s', log_file.name)
# parse logfile line by line and filter lines without events for line in log_file:
events = filter(None, map(_parse_line, f)) match = re_log_entry.match(line)
all_events = list(itertools.chain.from_iterable(events)) if not match:
# chain apply events to Client-obj logger.debug('No match: "%s"', line)
clients.apply_events(all_events) continue
match = match.groupdict()
logdatetime = tz_aware_datime(datetime.strptime(match['timestamp'],
log_timestamp_format))
message = match['message']
if message.startswith('client'):
match = re_dis_connect.match(message)
if not match:
logger.debug('Not supported client action: "%s"', message)
continue
nick, clid = match.group('nick'), match.group('clid')
client = clients.setdefault(
clid, Client(clients.ident_map.get(clid, clid), nick)
)
# set current nick
client.nick = nick
# add nick to history
client.nick_history.add(nick)
# find connected clients action = match.group('action')
online_clients = list( if action == 'connected':
filter(lambda c: c.connected, clients.values()) client.connect(logdatetime)
) elif action == 'disconnected':
client.disconnect(logdatetime)
if online_clients: if 'invokeruid' in message:
logger.debug( re_disconnect_data = re_disconnect_invoker.findall(
'Some clients are still connected: %s', online_clients message)
) invokernick, invokeruid = re_disconnect_data[0]
if index == len(logs) - 1: invoker = clients.setdefault(invokeruid,
if online_dc: Client(invokeruid))
logger.debug( invoker.nick = invokernick
'Last log => disconnecting online clients' if 'bantime' in message:
) invoker.ban(client)
# last iteration
# => disconnect online clients if desired
for online_client in online_clients:
online_client.disconnect(pendulum.now('UTC'))
online_client.connected += 1
else: else:
logger.warn( invoker.kick(client)
'Server didn\'t disconnect all clients on shutdown' elif message == 'stopped':
' or logfile is incorrectly named/corrupted (%s).' # make sure all clients are disconnected at server stop
' Check debuglog for details', [
f.name client.disconnect(logdatetime)
) for client in clients
logger.debug( if client.connected
'Will handle this by disconnecting all clients on' ]
' last event timestamp' if online_dc:
) def _reconnect(client):
last_event_timestamp = all_events[-1].timestamp client.disconnect(tz_aware_datime(datetime.utcnow()))
logger.debug( client.connected += 1
'Last event timestamp: %s', last_event_timestamp) [_reconnect(client) for client in clients if client.connected]
for online_client in online_clients: logger.debug(
online_client.disconnect(last_event_timestamp) 'Finished parsing of %s in %s seconds',
log_file.name, time() - start_time
logger.debug('Finished parsing of %s', f.name) )
if len(clients) >= 1: return clients
# assemble Server-obj and yield
yield Server(virtualserver_id, clients)

View file

@ -1,13 +1,16 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging import logging
from collections import namedtuple from collections import namedtuple
from datetime import datetime
from os.path import dirname, join from os.path import dirname, join
import pendulum
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PackageLoader from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PackageLoader
from tsstats.log import Server from tsstats.log import Server
from tsstats.utils import filter_threshold, seconds_to_text, sort_clients from tsstats.utils import (
filter_threshold, seconds_to_text, sort_clients, tz_aware_datime
)
logger = logging.getLogger('tsstats') logger = logging.getLogger('tsstats')
@ -30,12 +33,19 @@ def prepare_clients(clients, onlinetime_threshold=-1):
:return: `clients` sorted by onlinetime, kics, pkicks, bans and pbans :return: `clients` sorted by onlinetime, kics, pkicks, bans and pbans
:rtype: tsstats.template.SortedClients :rtype: tsstats.template.SortedClients
''' '''
# drop current nick from nick-history
[
c.nick_history.remove(c.nick)
for c in clients
if c.nick in c.nick_history
]
# sort by onlinetime # sort by onlinetime
onlinetime_ = sort_clients( onlinetime_ = sort_clients(
clients, lambda c: c.onlinetime.total_seconds() clients, lambda c: c.onlinetime.total_seconds()
) )
# filter clients not matching threshold # filter clients not matching threshold
onlinetime_ = filter_threshold(onlinetime_, onlinetime_threshold) onlinetime_ = filter_threshold(onlinetime_,
onlinetime_threshold)
# convert timespans to text # convert timespans to text
onlinetime = [ onlinetime = [
(client, seconds_to_text(int(onlinetime))) (client, seconds_to_text(int(onlinetime)))
@ -52,7 +62,7 @@ def prepare_clients(clients, onlinetime_threshold=-1):
def render_servers(servers, output, title='TeamspeakStats', def render_servers(servers, output, title='TeamspeakStats',
template='index.jinja2', datetime_fmt='%x %X %Z', template='index.jinja2', datetime_fmt='%x %X %Z',
onlinetime_threshold=-1, lastseen_relative=True): onlinetime_threshold=-1):
''' '''
Render `servers` Render `servers`
@ -63,7 +73,6 @@ def render_servers(servers, output, title='TeamspeakStats',
:param template_path: path to template-file :param template_path: path to template-file
:param datetime_fmt: custom datetime-format :param datetime_fmt: custom datetime-format
:param onlinetime_threshold: threshold for clients onlinetime :param onlinetime_threshold: threshold for clients onlinetime
:param lastseen_relative: render last seen timestamp relative
:type servers: [tsstats.log.Server] :type servers: [tsstats.log.Server]
@ -73,7 +82,6 @@ def render_servers(servers, output, title='TeamspeakStats',
:type template_path: str :type template_path: str
:type datetime_fmt: str :type datetime_fmt: str
:type onlinetime_threshold: int :type onlinetime_threshold: int
:type lastseen_relative: bool
''' '''
# preparse servers # preparse servers
prepared_servers = [ prepared_servers = [
@ -93,18 +101,11 @@ def render_servers(servers, output, title='TeamspeakStats',
formatted = timestamp.strftime(datetime_fmt) formatted = timestamp.strftime(datetime_fmt)
logger.debug('Formatting timestamp %s -> %s', timestamp, formatted) logger.debug('Formatting timestamp %s -> %s', timestamp, formatted)
return formatted return formatted
def lastseen(timestamp):
if lastseen_relative:
return timestamp.diff_for_humans()
else:
return frmttime(timestamp)
template_env.filters['frmttime'] = frmttime template_env.filters['frmttime'] = frmttime
template_env.filters['lastseen'] = lastseen
template = template_env.get_template(template) template = template_env.get_template(template)
logger.debug('Rendering template %s', template) logger.debug('Rendering template %s', template)
template.stream(title=title, servers=prepared_servers, template.stream(title=title, servers=prepared_servers,
debug=logger.level <= logging.DEBUG, debug=logger.level <= logging.DEBUG,
creation_time=pendulum.now('UTC'))\ creation_time=tz_aware_datime(datetime.utcnow()))\
.dump(output, encoding='utf-8') .dump(output, encoding='utf-8')
logger.debug('Wrote rendered template to %s', output) logger.debug('Wrote rendered template to %s', output)

View file

@ -3,20 +3,20 @@
<head> <head>
<title>{{ title }}</title> <title>{{ title }}</title>
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous"> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/hint.css@2.6.0/hint.min.css" integrity="sha256-UMhOZKeAbUSd/AoZKm+rlqzsBhzI7dTOYf2Euns4Es8=" crossorigin="anonymous"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/hint.css/2.4.1/hint.min.css" integrity="sha256-7KczUWqIa/6KaIKtNfG18eilVQR4vJ4S9SSiDAplUwc=" crossorigin="anonymous">
<meta name="referrer" content="no-referrer"> <meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css"> <style type="text/css">
body {
padding-top: 50px;
}
a { a {
color: inherit; color: inherit;
} }
a:hover { a:hover {
text-decoration: none; text-decoration: none;
} }
.navbar-toggler {
cursor: pointer;
}
@media screen and (max-width: 767px) { @media screen and (max-width: 767px) {
.hint--medium--xs:after { .hint--medium--xs:after {
white-space: normal; white-space: normal;
@ -27,53 +27,27 @@
</style> </style>
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-md navbar-light bg-light sticky-top">
<a href="" class="navbar-brand">{{ title }}</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="main-nav" aria-controls="main-nav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div id="main-nav" class="collapse navbar-collapse">
<ul class="navbar-nav mr-auto">
{% for sid, _ in servers %}
<li class="nav-item">
<a class="nav-link" href="#sid{{ sid }}">Server {{ sid }}</a>
</li>
{% endfor %}
</ul>
{% if debug %}
<span class="navbar-text" style="color: red">debug mode</span>
{% endif %}
</div>
</nav>
<div class="container-fluid"> <div class="container-fluid">
<nav class="navbar navbar-default navbar-fixed-top"><div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#">{{ title }}</a>
</div>
<ul class="nav navbar-nav">
{% for sid, _ in servers %}
<li><a href="#sid{{ sid }}">Server {{ sid }}</a></li>
{% endfor %}
</ul>
{% if debug %}
<p class="navbar-text navbar-right" style="color: red; padding-right: 10px;">debug mode</p>
{% endif %}
</div></nav>
{% for server in servers %} {% for server in servers %}
<h1 class="display-4" id="sid{{ server.sid }}"> <h1 class="page-header" id="sid{{ server.sid }}">
<a href="#sid{{ server.sid }}">Server {{ server.sid }}</a> <a href="#sid{{ server.sid }}">Server {{ server.sid }}</a>
</h1> </h1>
{% include 'stats.jinja2' %} {% include 'stats.jinja2' %}
{% endfor %} {% endfor %}
<small>Generated by <a href="https://github.com/Thor77/TeamspeakStats" rel="noopener">TeamspeakStats</a> at {{ creation_time|frmttime }}</small> <small>Generated by <a href="https://github.com/Thor77/TeamspeakStats" rel="noopener">TeamspeakStats</a> at {{ creation_time|frmttime }}</small>
</div> </div>
<script type="text/javascript">
(function() {
var collapseTogglers = document.querySelectorAll('[data-toggle="collapse"]');
var toggleTarget;
Array.prototype.forEach.call(collapseTogglers, function(toggler) {
toggler.addEventListener('click', function(event) {
toggleTarget = document.getElementById(toggler.dataset.target);
if (toggler.getAttribute('aria-expanded') === 'true') {
toggleTarget.classList.add('collapse');
toggler.setAttribute('aria-expanded', false);
} else {
toggleTarget.classList.remove('collapse');
toggler.setAttribute('aria-expanded', true);
}
});
});
})();
</script>
</body> </body>
</html> </html>

View file

@ -9,12 +9,12 @@
{% if clients|length > 0 %} {% if clients|length > 0 %}
{% set headline_id = [server.sid, headline|lower|replace(' ', '_')]|join('.') %} {% set headline_id = [server.sid, headline|lower|replace(' ', '_')]|join('.') %}
<h2><a href="#{{ headline_id }}">{{ headline }}</a></h2> <h2><a href="#{{ headline_id }}">{{ headline }}</a></h2>
<ul class="list-group my-3" id="{{ headline_id }}"> <ul class="list-group" id="{{ headline_id }}">
{% for client, value in clients %} {% for client, value in clients %}
{% set id = [headline_id, client.nick|striptags]|join('.') %} {% set id = [headline_id, client.nick|striptags]|join('.') %}
<li id="{{ id }}" class="list-group-item d-flex justify-content-between align-items-center{{ ' list-group-item-success' if client.connected else loop.cycle('" style="background-color: #eee;', '') }}"> <li id="{{ id }}" class="list-group-item{{ ' list-group-item-success' if client.connected else loop.cycle('" style="background-color: #eee;', '') }}">
<span class="hint--right hint--medium--xs hint--no-shadow" data-hint="{{ client.nick_history|join(', ') }}"><a href="#{{ id }}">{{ client.nick }}{{ " (" + client.identifier + ")" if debug }}</a></span> <span class="hint--right hint--medium--xs" data-hint="{{ client.nick_history|join(', ') }}"><a href="#{{ id }}">{{ client.nick }}{{ " (" + client.identifier + ")" if debug }}</a></span>
<span class="badge badge-secondary badge-pill"><div{% if not client.connected and headline == 'Onlinetime' %} class="hint--left hint--no-shadow" data-hint="{{ client.last_seen|lastseen }}"{% endif %}>{{ value }}</div></span> <span class="badge"><div{% if not client.connected and headline == 'Onlinetime' %} class="hint--left" data-hint="{{ client.last_seen|frmttime }}"{% endif %}>{{ value }}</div></span>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View file

@ -1 +0,0 @@
# -*- coding: utf-8 -*-

View file

@ -1,14 +0,0 @@
# -*- coding: utf-8 -*-
from os import remove
import pytest
@pytest.fixture
def output(request):
output_path = 'tsstats/tests/res/output.html'
def clean():
remove(output_path)
request.addfinalizer(clean)
yield output_path

View file

@ -0,0 +1,3 @@
2016-06-18 14:22:12.161100|INFO |VirtualServer| 1| listening on 0.0.0.0:9987
2015-05-18 16:00:14.951191|INFO |VirtualServerBase| 3| client disconnected 'Client1'(id:1) reason 'reasonmsg=ByeBye!'
2015-05-18 15:55:23.456679|INFO |VirtualServerBase| 3| client connected 'Client1'(id:1) from 1.2.3.4:1234

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
import pytest import pytest
from tsstats.client import Client, Clients from tsstats.client import Client, Clients
@ -31,28 +30,15 @@ def test_client_get(clients):
def test_client_repr(clients): def test_client_repr(clients):
clients, _, _, _, _ = clients clients, _, _, _, _ = clients
assert str(clients['1']) == '<1, None>' assert str(clients['1']) == '<1,None>'
assert str(clients['2']) == '<2, None>' assert str(clients['2']) == '<2,None>'
assert str(clients['UID1']) == '<UID1, None>' assert str(clients['UID1']) == '<UID1,None>'
assert str(clients['UID2']) == '<UID2, None>' assert str(clients['UID2']) == '<UID2,None>'
assert repr(clients['1']) == str(clients['1'])
def test_client_nick(clients):
_, cl1, _, _, _ = clients
assert cl1.nick is None
assert not cl1.nick_history
cl1.nick = 'Client1'
assert cl1.nick == 'Client1'
assert None not in cl1.nick_history
cl1.nick = 'NewClient1'
assert cl1.nick == 'NewClient1'
assert 'Client1' in cl1.nick_history
def test_clients_iter(clients): def test_clients_iter(clients):
clients, cl1, cl2, uidcl1, uidcl2 = clients clients, cl1, cl2, uidcl1, uidcl2 = clients
client_list = list(clients.values()) client_list = list(iter(clients))
assert cl1 in client_list assert cl1 in client_list
assert cl2 in client_list assert cl2 in client_list
assert uidcl1 in client_list assert uidcl1 in client_list
@ -62,4 +48,4 @@ def test_clients_iter(clients):
def test_clients_delete(clients): def test_clients_delete(clients):
clients, cl1, _, _, _ = clients clients, cl1, _, _, _ = clients
del clients['1'] del clients['1']
assert '1' not in clients assert cl1 not in clients

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
import pytest import pytest
from tsstats.config import load from tsstats.config import load

View file

@ -1,18 +1,17 @@
# -*- coding: utf-8 -*-
import pytest import pytest
from tsstats.client import Client, Clients from tsstats.client import Client, Clients
from tsstats.log import parse_logs from tsstats.log import _parse_details
from tsstats.utils import transform_pretty_identmap from tsstats.utils import transform_pretty_identmap
@pytest.fixture(scope='module') @pytest.fixture(scope='module')
def identmap_clients(): def identmap_clients():
clients = Clients({ clients = Clients({
'1': '2', '1': '2',
'5': '2', '5': '2',
'UID1': 'UID2', 'UID1': 'UID2',
'UID5': 'UID2' 'UID5': 'UID2'
}) })
cl = Client('2', 'Client2') cl = Client('2', 'Client2')
uidcl = Client('UID2', 'Client2++') uidcl = Client('UID2', 'Client2++')
@ -56,12 +55,12 @@ def test_transform_pretty_identmap(test_input, expected):
def test_ident_map_wrong_identifier(): def test_ident_map_wrong_identifier():
clients = list(parse_logs( clients = _parse_details(
'tsstats/tests/res/test.log.identmap_wrong_identifier', ident_map={ 'tsstats/tests/res/test.log.identmap_wrong_identifier', ident_map={
'2': '1', '2': '1',
'3': '1' '3': '1'
} }
))[0].clients )
client = clients.get('1') client = clients.get('1')
# assert client exists # assert client exists
assert client assert client

View file

@ -1,19 +1,20 @@
# -*- coding: utf-8 -*- from datetime import datetime, timedelta
import pendulum from os import remove
from time import sleep
import pytest import pytest
from tsstats import events from tsstats.exceptions import InvalidLog
from tsstats.log import TimedLog, _bundle_logs, _parse_line, parse_logs from tsstats.log import TimedLog, _bundle_logs, _parse_details, parse_logs
from tsstats.template import render_servers from tsstats.template import render_servers
from tsstats.tests.test_template import output_path
testlog_path = 'tsstats/tests/res/test.log' testlog_path = 'tsstats/tests/res/test.log'
static_timestamp = pendulum.datetime(2015, 5, 18, 15, 52, 52, 685612)
@pytest.fixture @pytest.fixture
def clients(): def clients():
return list(parse_logs(testlog_path, online_dc=False))[0].clients return _parse_details(testlog_path, online_dc=False)
def test_log_client_count(clients): def test_log_client_count(clients):
@ -21,10 +22,8 @@ def test_log_client_count(clients):
def test_log_onlinetime(clients): def test_log_onlinetime(clients):
assert clients['1'].onlinetime == pendulum.duration( assert clients['1'].onlinetime == timedelta(0, 402, 149208)
seconds=402, microseconds=149208) assert clients['2'].onlinetime == timedelta(0, 19, 759644)
assert clients['2'].onlinetime == pendulum.duration(
seconds=19, microseconds=759644)
def test_log_kicks(clients): def test_log_kicks(clients):
@ -55,20 +54,12 @@ def test_log_pbans(clients):
], ],
{ {
'1': [ '1': [
TimedLog( TimedLog('ts3server_2016-06-06__14_22_09.527229_1.log',
'ts3server_2016-06-06__14_22_09.527229_1.log', datetime(year=2016, month=6, day=6, hour=14,
pendulum.datetime( minute=22, second=9, microsecond=527229)),
year=2016, month=6, day=6, hour=14, minute=22, TimedLog('ts3server_2017-07-07__15_23_10.638340_1.log',
second=9, microsecond=527229 datetime(year=2017, month=7, day=7, hour=15,
) minute=23, second=10, microsecond=638340))
),
TimedLog(
'ts3server_2017-07-07__15_23_10.638340_1.log',
pendulum.datetime(
year=2017, month=7, day=7, hour=15, minute=23,
second=10, microsecond=638340
)
)
] ]
} }
) )
@ -77,68 +68,37 @@ def test_log_bundle(logs, bundled):
assert _bundle_logs(logs) == bundled assert _bundle_logs(logs) == bundled
def test_log_invalid():
with pytest.raises(InvalidLog):
_parse_details('tsstats/tests/res/test.log.broken')
@pytest.mark.slowtest
def test_log_client_online(): def test_log_client_online():
current_time = pendulum.now() clients = _parse_details(testlog_path)
pendulum.set_test_now(current_time)
clients = list(parse_logs(testlog_path))[0].clients
old_onlinetime = int(clients['1'].onlinetime.total_seconds()) old_onlinetime = int(clients['1'].onlinetime.total_seconds())
sleep(2)
pendulum.set_test_now(current_time.add(seconds=2)) # add 2s to .now() clients = _parse_details(testlog_path)
clients = list(parse_logs(testlog_path))[0].clients
assert int(clients['1'].onlinetime.total_seconds()) == old_onlinetime + 2 assert int(clients['1'].onlinetime.total_seconds()) == old_onlinetime + 2
def test_parse_logs():
assert len(_parse_details(testlog_path)) ==\
len(parse_logs(testlog_path)[0].clients)
def test_parse_groups(): def test_parse_groups():
server = list(parse_logs('tsstats/tests/res/test.log.groups')) clients = _parse_details('tsstats/tests/res/test.log.groups')
assert len(server) == 0 assert len(clients) == 0
def test_parse_utf8(output): def test_parse_utf8():
servers = parse_logs(testlog_path + '.utf8') servers = parse_logs(testlog_path + '.utf8')
render_servers(servers, output) render_servers(servers, output_path)
remove(output_path)
def test_parse_invalid_line(): def test_server_stop():
assert _parse_line('INVALID') == [] clients = _parse_details('tsstats/tests/res/test.log.stopped')
assert clients['1'].onlinetime.seconds / 60 == 20 # minutes
assert clients['2'].onlinetime.seconds / 60 == 10 # minutes
@pytest.mark.parametrize('line,expected_events', [
(
"client connected 'Client1'(id:1) from 1.2.3.4:1234",
[
events.connect(static_timestamp, '1')
]
),
(
"client disconnected 'Client1'(id:1) reason 'reasonmsg=ByeBye!'",
[
events.disconnect(static_timestamp, '1')
]
),
(
"client disconnected 'Client1'(id:1) reason 'invokerid=1"
" invokername=Client2 invokeruid=UIDClient2 reasonmsg'",
[
events.disconnect(static_timestamp, '1'),
events.nick(None, 'UIDClient2', 'Client2'),
events.kick(None, 'UIDClient2', '1')
]
),
(
"client disconnected 'Client1'(id:1) reason 'invokerid=2 "
"invokername=Client2 invokeruid=UIDClient2 reasonmsg bantime=0'",
[
events.disconnect(static_timestamp, '1'),
events.nick(None, 'UIDClient2', 'Client2'),
events.ban(None, 'UIDClient2', '1')
]
)
])
def test_parse_line(line, expected_events):
line = '2015-05-18 15:52:52.685612|INFO |VirtualServerBase| 3| ' + line
expected_events.insert(0, events.nick(None, '1', 'Client1'))
expected_events = [
event._replace(timestamp=static_timestamp) for event in expected_events
]
assert _parse_line(line) == expected_events

View file

@ -1,35 +1,41 @@
# -*- coding: utf-8 -*-
import logging import logging
from datetime import timedelta
from os import remove
import pendulum
import pytest import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from tsstats.log import parse_logs from tsstats.log import Server, _parse_details
from tsstats.template import render_servers from tsstats.template import render_servers
from tsstats.utils import filter_threshold, seconds_to_text, sort_clients from tsstats.utils import filter_threshold, seconds_to_text, sort_clients
servers = list(parse_logs('tsstats/tests/res/test.log', online_dc=False)) output_path = 'tsstats/tests/res/output.html'
servers[0] = servers[0]._replace(sid=1) # add missing sid to server object clients = _parse_details('tsstats/tests/res/test.log', online_dc=False)
clients = servers[0].clients servers = [Server(1, clients)]
logger = logging.getLogger('tsstats') logger = logging.getLogger('tsstats')
@pytest.fixture
def output(request):
def clean():
remove(output_path)
request.addfinalizer(clean)
@pytest.fixture @pytest.fixture
def soup(output): def soup(output):
render_servers(servers, output) render_servers(servers, output_path)
return BeautifulSoup(open(output), 'html.parser') return BeautifulSoup(open(output_path), 'html.parser')
def test_debug(output): def test_debug(output):
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
render_servers(servers, output) render_servers(servers, output_path)
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
soup = BeautifulSoup(open(output), 'html.parser') soup = BeautifulSoup(open(output_path), 'html.parser')
# check debug-label presence # check debug-label presence
assert soup.find('nav').find('div', id='main-nav').find('span').text \ assert soup.find_all(style='color: red; padding-right: 10px;')
== 'debug mode'
for client_item in soup.find('ul', id='1.onlinetime').find_all('li'): for client_item in soup.find('ul', id='1.onlinetime').find_all('li'):
nick = client_item.find('span').text nick = client_item.find('span').text
# check for right identifier # check for right identifier
@ -47,8 +53,8 @@ def test_onlinetime(soup):
onlinetime = onlinetime.text onlinetime = onlinetime.text
# find corresponding client-object # find corresponding client-object
client = list(filter( client = list(filter(
lambda c: c.nick == nick and c.onlinetime > pendulum.duration(), lambda c: c.nick == nick and c.onlinetime > timedelta(0),
clients.values() clients
)) ))
# assert existence # assert existence
assert client assert client
@ -66,16 +72,3 @@ def test_filter_threshold():
assert len(filter_threshold(sorted_clients, -1)) == len(sorted_clients) assert len(filter_threshold(sorted_clients, -1)) == len(sorted_clients)
assert len(filter_threshold(sorted_clients, 20)) == 1 assert len(filter_threshold(sorted_clients, 20)) == 1
assert len(filter_threshold(sorted_clients, 500)) == 0 assert len(filter_threshold(sorted_clients, 500)) == 0
def test_lastseen_relative(output):
render_servers(servers, output, lastseen_relative=True)
soup = BeautifulSoup(open(output), 'html.parser')
assert soup.find('ul', id='1.onlinetime')\
.select('div.hint--left')[0]['data-hint'] == \
pendulum.datetime(2015, 5, 18).diff_for_humans()
render_servers(servers, output, lastseen_relative=False)
soup = BeautifulSoup(open(output), 'html.parser')
assert soup.find('ul', id='1.onlinetime')\
.select('div.hint--left')[0]['data-hint'] == \
'05/18/15 15:54:38 UTC'

View file

@ -1,4 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import datetime
def sort_clients(clients, key_l): def sort_clients(clients, key_l):
''' '''
sort `clients` by `key` sort `clients` by `key`
@ -13,8 +16,7 @@ def sort_clients(clients, key_l):
:rtype: list :rtype: list
''' '''
cl_data = [ cl_data = [
(client, key_l(client)) for client in clients.values() (client, key_l(client)) for client in clients if key_l(client) > 0
if key_l(client) > 0
] ]
return sorted(cl_data, key=lambda data: data[1], reverse=True) return sorted(cl_data, key=lambda data: data[1], reverse=True)
@ -50,6 +52,34 @@ def filter_threshold(clients, threshold):
return list(filter(lambda c: c[1] > threshold, clients)) return list(filter(lambda c: c[1] > threshold, clients))
class UTC(datetime.tzinfo):
'''
Reimplementation of `timezone.utc` for Python2-Compatibility
'''
def utcoffset(self, dt):
return datetime.timedelta(0)
def dst(self, dt):
return datetime.timedelta(0)
def tzname(self, dt):
return 'UTC'
def tz_aware_datime(datetime, timezone=UTC()):
'''
Make `datetime` aware of it's timezone (UTC by default)
:param datetime: Target datetime
:param timezone: Target timezone
:type datetime: datetime.datetime
:type timezone: datetime.timezone
'''
return datetime.replace(tzinfo=timezone)
def transform_pretty_identmap(pretty_identmap): def transform_pretty_identmap(pretty_identmap):
''' '''
Transforms a list of client ID mappings from a more descriptive format Transforms a list of client ID mappings from a more descriptive format