Compare commits

...

108 commits

Author SHA1 Message Date
Thor77
6e069659b7
Bump version to 2.1.0 2023-01-14 19:03:31 +01:00
Thor77
4c872930db
Update pendulum to 2.1.2 2023-01-14 19:02:53 +01:00
Thor77
1a7c1cce9d
Fix secret not passed to publish command 2023-01-14 18:47:58 +01:00
Thor77
dcc26830cb
Remove test call, already running for branch 2023-01-14 18:47:55 +01:00
Thor77
7ae09263e1
Bump version to 2.0.2 2023-01-14 18:41:07 +01:00
Thor77
1d0e3a5fdf Add publish workflow 2023-01-14 18:39:20 +01:00
Thor77
51abb87c88 Fix lint errors 2023-01-14 18:39:20 +01:00
Thor77
43e759c714 Add include and scripts 2023-01-14 18:39:20 +01:00
Thor77
8ff880645a Use poe to run test and lint task 2023-01-14 18:39:20 +01:00
Thor77
4a22a06a91 Drop python 3.6 2023-01-14 18:39:20 +01:00
Thor77
4d09f9d82c Switch to GitHub actions 2023-01-14 18:39:20 +01:00
Thor77
60f102cc34 Switch to Poetry for dependency management 2023-01-14 18:39:20 +01:00
Thor77
8223c389d3 Fix Python 3.10 compatability 2023-01-14 18:39:20 +01:00
Thor77
0a443467be
Remove landscape badge completely 2020-03-27 22:55:51 +01:00
Thor77
561f1b6e4d
Remove landscape badge
because the service seems to be gone
2020-03-27 22:55:10 +01:00
Thor77
e90e3fad43
Drop testing for Python 2 2020-03-27 22:52:34 +01:00
Thor77
61c6022e37
Start testing with Python 3.8 2020-03-27 22:47:12 +01:00
Thor77
f6c5be663a
Update dependencies 2020-03-27 22:47:03 +01:00
Thor77
a9c942d442
Disable shadows on hints 2020-03-27 22:39:50 +01:00
Thor77
127370adbd
Update bootstrap and hint.css 2020-03-27 22:39:37 +01:00
Thor77
b327c4e43d Update dependencies 2019-09-07 15:40:15 +02:00
Thor77
40874d525b
Update dependencies 2019-04-09 18:34:51 +02:00
Thor77
c7f9731b6a
Fix testing requirements filename 2019-03-10 13:16:15 +01:00
Thor77
604dc0a439
Switch to bootstrapcdn for boostrap css 2019-03-10 13:14:17 +01:00
Thor77
b8dbd442f2
Use Python 3.7 for Travis tests 2019-03-10 13:10:37 +01:00
Thor77
18f1576bfb
Remove testing-requirements.txt (replaced by -dev) 2019-03-10 13:10:09 +01:00
Thor77
7f88f0376c
Lock dependencies in requirements.txt 2019-03-10 13:08:57 +01:00
Thor77
1f177ff49b Bump css dependencies 2019-03-10 13:06:28 +01:00
Thor77
544da4062f Use bootstrap-4.0.0 2019-03-10 13:06:28 +01:00
Matthew
35c5c91fb0 Simplify navbar markup, update it for bootstrap4 beta1. (#23)
* Simplify navbar markup, update it for bootstrap4 beta1.

* Fix CSS classname for background color of navbar
2019-03-10 13:06:28 +01:00
Thor77
21f9f6581b Switch badge-type to secondary
because -default is now invisible
2019-03-10 13:06:28 +01:00
Thor77
371af752eb Add flexbox-classes to listgroup-item
to align badges correctly
2019-03-10 13:06:28 +01:00
Thor77
b81b1bca10 Switch css-include to bootstrap4-beta 2019-03-10 13:06:28 +01:00
Matthew
c34f2d4d8b Improve navbar on mobile devices (#16)
* add button to show/hide menu items
* ensure navigation fills the width of the viewport by moving it out of the main container element
2019-03-10 13:06:28 +01:00
Thor77
3207c0070c Don't rely on style-attr for debug-label presence 2019-03-10 13:06:28 +01:00
Thor77
4069ab659a Add margin for list-groups 2019-03-10 13:06:28 +01:00
Thor77
0dc12e73a1 Replace deprecated page-header heading-class
with `display-4`
2019-03-10 13:06:28 +01:00
Thor77
b474e4124d Remove now unneccessary padding from body 2019-03-10 13:06:28 +01:00
Thor77
2344915d6e Use new navbar-syntax
removed padding from debug-badge as it's not required anymore
2019-03-10 13:06:28 +01:00
Thor77
3ce4962e12 Use new badge-syntax
explicit definition of badge-style and -position
2019-03-10 13:06:28 +01:00
Thor77
dc572ad8fa Switch css-include to bootstrap4 2019-03-10 13:06:28 +01:00
Thor77
c14e7f1068
Update dependencies 2019-03-10 12:58:36 +01:00
Thor77
687f58e247 Bump version to 2.0.1 2018-05-19 18:10:30 +02:00
Thor77
f878fefeaa Use raw strings for RegEx's 2018-05-19 18:06:59 +02:00
Thor77
187ae0afc5 Lock dependencies in {testing_,}requirements.txt 2018-05-19 18:01:08 +02:00
Thor77
a019278e79 Lock pendulum to 1.5.1 in setup.py 2018-05-19 17:49:43 +02:00
Thor77
e92f5ea5ff Lock pendulum to version 1.5.1
because there are breaking changes in 2.0.0
2018-05-19 17:43:33 +02:00
Thor77
1ef4a3bc15 Handle connected clients on unexpected shutdown
caused, for example, by a server crash.
Could be triggered by an incorrectly named logfile as well because it is
assumed once there are connected clients in a logfile which isn't the
last one to be parsed.

The fix is realized by taking the timestamp of the last event from the
currently parsed logfile and disconnecting all still connected clients
on that timestamp.
2018-05-19 17:28:03 +02:00
Thor77
6e40555612 Only log online_clients if there are any
at the end of a logfile.
2018-05-02 20:59:23 +02:00
Thor77
5d9507deb0 Add Pipfile 2018-03-02 23:59:21 +01:00
Thor77
ae13390b7b Use pylama for stylechecks 2018-02-01 21:49:02 +01:00
Thor77
5ea2f6ab3d Remove extra lines after encoding header 2018-02-01 20:49:23 +01:00
Thor77
489d609807 Add pytests .cache to .gitignore 2018-02-01 17:02:56 +01:00
Thor77
56471137c0 Limit build status on AppVeyor to master-branch 2018-01-30 19:14:51 +01:00
Thor77
4310f93adc Bump version to 2.0.0 2018-01-19 22:28:28 +01:00
Thor77
ab68f57f83 Use timestamp for relative timestamp human test 2018-01-10 20:33:52 +01:00
Thor77
7fd4297c4d Adapt tests to Clients.__iter__ returning keys 2017-09-25 23:21:12 +02:00
Thor77
8d1c19a734 Adapt log.parse_logs and utils.sort_clients
to Clients.__iter__ return keys instead of values
2017-09-25 23:21:06 +02:00
Thor77
2ebd445349 Clients.__iter__ return keys instead of values
as desired by MutableMapping.__iter__
2017-09-25 23:14:26 +02:00
Thor77
c9ab6f6b97 Add encoding-header to missing files 2017-09-16 22:31:15 +02:00
Thor77
c8092018f2 Revert "Sort imports"
because the sorting-order is dependent on installed packages

This reverts commit 51191672c6.
2017-09-16 22:03:55 +02:00
Thor77
51191672c6 Sort imports 2017-09-16 21:59:44 +02:00
Thor77
871210dde4 Add test for tsstats.log._parse_line 2017-09-15 11:35:21 +02:00
Thor77
1e1f112867 Add funcdoc to tsstats.log._parse_line 2017-09-15 10:51:29 +02:00
Thor77
a9e8cd0b6e Revert "Add test for Clients.__str__"
This reverts commit b9f798d04d.
2017-09-14 23:37:10 +02:00
Thor77
8558d731d4 Add test for render_servers.lastseen_relative 2017-09-14 23:09:50 +02:00
Thor77
f786c87dfb Add test for Client.__repr__ 2017-09-14 22:39:12 +02:00
Thor77
52f5cc3ac1 Add test for invalid log line 2017-09-14 22:37:56 +02:00
Thor77
b9f798d04d Add test for Clients.__str__ 2017-09-14 22:37:32 +02:00
Thor77
6345c3f1f5 Remove test for serverstop with connected clients
because it is non-trivial to implement with the new architecture and
probably not worth the effort, because it basically can't happen.
A warning/exception should be added instead.
2017-09-10 00:15:21 +02:00
Thor77
b1b80f657a Use parse_logs instead of _parse_details 2017-09-10 00:04:11 +02:00
Thor77
cbc76b5541 Add missing identmap-lookup for new clients 2017-09-09 18:44:41 +02:00
Thor77
1c224fa0ee Use parse_logs instead of _parse_details 2017-09-09 18:36:43 +02:00
Thor77
c79dd08bc0 Don't accept infinite arguments
leftover from old parse_logs-func
2017-09-09 18:04:56 +02:00
Thor77
147c41ffce Use parse_logs instead of _parse_details
because _parse_details is not available anymore
2017-09-09 17:35:55 +02:00
Thor77
df268f1c2a Add return and rtype to parse_logs-funcdoc 2017-09-08 15:39:39 +02:00
Thor77
602e6c4d51 Log start/end of parsing logfile 2017-09-08 15:39:39 +02:00
Thor77
91a9b8e4c7 Implement online_dc again 2017-09-08 15:39:39 +02:00
Thor77
90a367da27 Warn about online clients on logfile end 2017-09-08 15:39:39 +02:00
Thor77
59d4c88701 Add Client.__repr__ 2017-09-08 15:39:39 +02:00
Thor77
3d6c41538b Indent return of parsed_events correctly 2017-09-08 15:39:39 +02:00
Thor77
96d6e9f050 Only yield Server if there are clients for it 2017-09-08 15:39:39 +02:00
Thor77
7077446627 Add test for Client.nick-property 2017-09-08 15:39:39 +02:00
Thor77
3acf282470 Don't drop current nick in prepare_clients
because that's already handled in the Client.nick-property now
2017-09-08 15:39:39 +02:00
Thor77
c2fb6aa6c1 Handle set_nick event-action with .nick-property 2017-09-08 15:39:39 +02:00
Thor77
088d905196 Convert Client.nick into property
to add previous nick to .nick_history on set
2017-09-08 15:39:39 +02:00
Thor77
a084101ced Add Clients.apply_events
to apply events to a Clients-collection
2017-09-08 15:39:39 +02:00
Thor77
da2b773bf6 Add tsstats/event.py for easy event-initialization 2017-09-08 15:39:39 +02:00
Thor77
08b4e06f10 Refactor and simplify log-parsing
* _parse_line parses one line at a time for simplified testing
and return a list of event.Event's instead of applying changes directly
to a Clients-obj
* parse_log just bundles the logs (using _bundle_logs), opens them,
parses them (using _parse_line) and applies returned events to a
Clients-obj in the correct order

With these changes some sort of caching is possible because events are
not bound to a specific client-object and are easily sortable due to
their attached timestamp.
2017-09-08 15:39:39 +02:00
Thor77
20d40c8890 Add string-representation for Clients
to simplify debug-output
2017-09-08 15:39:39 +02:00
Thor77
caff246f9a Don't raise InvalidLog
because there's nothing you can do about it anyways, so there's no need
to stop.
2017-09-08 14:12:48 +02:00
Thor77
6a84b35a52 [requires.io] Dependency update (#24) 2017-09-05 22:18:04 +02:00
Thor77
7ab4436777 Bump version to 1.5.1 2017-08-24 19:38:44 +02:00
Thor77
3067c229d6 Add pendulum requirement to setup.py 2017-08-24 19:38:20 +02:00
Thor77
7dbd37b028 Add pendulum to testing_requirements.txt 2017-08-03 23:39:50 +02:00
Thor77
22c15202a3 Bump version to 1.5.0 2017-07-07 16:19:28 +02:00
Thor77
edff1e956d Optionally Pendulum.diff_for_humans instead of frmttime
for relative last seen time (default)

* introduce --lastseenabsolute
* introduce tsstats.template.render_servers(lastseen_relative=True)
2017-07-07 14:23:10 +02:00
Thor77
65a8379261 Use pendulum instead of plain datetime
because it is more intuitive to use and doesn't require the
tz_aware_datetime-workaround.
2017-07-07 14:22:24 +02:00
Thor77
3d469ce28c Sort imports 2017-06-02 23:34:33 +02:00
Thor77
6ddc8c94b7 Pass directory to flake8/isort 2017-06-02 23:33:40 +02:00
Thor77
de1cc4be2f Install (testing)requirements for style checks
to fix unexpected results from isort
2017-06-02 23:33:00 +02:00
Thor77
ed62eceda3 Bump version to 1.4.3 2017-06-01 23:41:55 +02:00
Thor77
11acf9f9b6 Add space between identifier and nick
mainly used in debug mode
2017-05-15 22:50:05 +02:00
Thor77
992d35ec87 Fix UnicodeEncodeError in debug mode with Python 2 2017-05-15 22:49:18 +02:00
Thor77
f209573d04 Fix Clients.__add__ not using identmap 2017-05-14 23:28:59 +02:00
Thor77
a4c04e34c8 Close log files after parsing 2017-05-11 11:32:11 +02:00
Thor77
fec833d876 Point to log-directory without glob in example
as the current example will trigger shell-expansion and possibly lead to
confusion (see #21)
2017-04-30 21:29:49 +02:00
Thor77
8299c73eb9 Move output-fixture to confest.py and yield path
* Modify test_log and test_template to use these changes
2017-04-28 23:27:05 +02:00
29 changed files with 1042 additions and 351 deletions

23
.github/workflows/publish.yml vendored Normal file
View file

@ -0,0 +1,23 @@
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 }}

42
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,42 @@
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,6 +1,7 @@
__pycache__/
*.py[cod]
venv/
.cache/
build/
dist/

View file

@ -1,26 +0,0 @@
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| |Code Health| |PyPI| |Documentation Status|
TeamspeakStats |Build Status| |Build status| |Coverage Status| |PyPI| |Documentation Status|
==========================================================================================================
A simple Teamspeak stat-generator - based solely on server-logs
@ -28,22 +28,19 @@ Example
::
tsstats -l /var/log/teamspeak3-server/ts3server*.log -o /var/www/tsstats.html
tsstats -l /var/log/teamspeak3-server/ -o /var/www/tsstats.html
Parse logs matching ``ts3server*.log`` in ``/var/log/teamspeak3-server``
and write output to ``/var/www/tsstats.html``.
Parse logs 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/>`__!
.. |screenshot| image:: https://raw.githubusercontent.com/Thor77/TeamspeakStats/master/screenshot.png
.. |Build Status| image:: https://travis-ci.org/Thor77/TeamspeakStats.svg?branch=master
:target: https://travis-ci.org/Thor77/TeamspeakStats
.. |Build status| image:: https://ci.appveyor.com/api/projects/status/u9cx7krwmmevbvl2?svg=true
.. |Build status| image:: https://ci.appveyor.com/api/projects/status/u9cx7krwmmevbvl2/branch/master?svg=true
:target: https://ci.appveyor.com/project/Thor77/teamspeakstats
.. |Coverage Status| image:: https://coveralls.io/repos/Thor77/TeamspeakStats/badge.svg?branch=master&service=github
: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
:target: https://pypi.python.org/pypi/tsstats
.. |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.
Those requirements are listed in ``testing_requirements.txt``::
$ pip install -r testing_requirement.txt
$ pip install -r requirements-dev.txt
$ py.test tsstats/tests/
Versioning

512
poetry.lock generated Normal file
View file

@ -0,0 +1,512 @@
# 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"

31
pyproject.toml Normal file
View file

@ -0,0 +1,31 @@
[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"

View file

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

View file

@ -1,24 +0,0 @@
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'
],
)

View file

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

View file

@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
import argparse
import json
import logging
@ -64,6 +63,11 @@ def cli():
'-otth', '--onlinetimethreshold',
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()
if 'config' in options:
configuration = config.load(options.config)
@ -115,7 +119,11 @@ def main(configuration):
template=configuration.get('General', 'template'),
datetime_fmt=configuration.get('General', 'datetimeformat'),
onlinetime_threshold=int(configuration.get(
'General', 'onlinetimethreshold'))
'General', 'onlinetimethreshold'
)),
lastseen_relative=configuration.getboolean(
'General', 'lastseenrelative'
)
)
logger.info('Finished after %s seconds', time() - start_time)

View file

@ -1,10 +1,7 @@
# -*- coding: utf-8 -*-
import datetime
import logging
from collections import MutableMapping
from tsstats.exceptions import InvalidLog
from collections.abc import MutableMapping
logger = logging.getLogger('tsstats')
@ -25,6 +22,29 @@ class Clients(MutableMapping):
self.store = dict()
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):
'''
Add a Client to the collection
@ -32,14 +52,15 @@ class Clients(MutableMapping):
:param client: Client to add to the collection
:type id_or_uid: Client
'''
self.store[client.identifier] = client
identifier = client.identifier
self.store[self.ident_map.get(identifier, identifier)] = client
return self
def __iter__(self):
'''
Yield all Client-objects from the collection
'''
return iter(self.store.values())
return iter(self.store.keys())
def __getitem__(self, key):
return self.store[self.ident_map.get(key, key)]
@ -53,6 +74,9 @@ class Clients(MutableMapping):
def __setitem__(self, key, value):
self.store[self.ident_map.get(key, key)] = value
def __str__(self):
return str(list(map(str, self)))
class Client(object):
'''
@ -68,7 +92,7 @@ class Client(object):
'''
# public
self.identifier = identifier
self.nick = nick
self._nick = nick
self.nick_history = set()
self.connected = 0
self.onlinetime = datetime.timedelta()
@ -80,6 +104,18 @@ class Client(object):
# private
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):
'''
Connect client at `timestamp`
@ -101,7 +137,7 @@ class Client(object):
logger.debug('[%s] DISCONNECT %s', timestamp, self)
if not self.connected:
logger.debug('^ disconnect before connect')
raise InvalidLog('disconnect before connect!')
return
self.connected -= 1
session_time = timestamp - self._last_connect
logger.debug('Session lasted %s', session_time)
@ -131,4 +167,7 @@ class Client(object):
self.bans += 1
def __str__(self):
return '<{},{}>'.format(self.identifier, self.nick)
return u'<{}, {}>'.format(self.identifier, self.nick)
def __repr__(self):
return self.__str__()

View file

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

34
tsstats/events.py Normal file
View file

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

View file

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

View file

@ -3,20 +3,20 @@
<head>
<title>{{ title }}</title>
<meta charset="utf-8">
<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/hint.css/2.4.1/hint.min.css" integrity="sha256-7KczUWqIa/6KaIKtNfG18eilVQR4vJ4S9SSiDAplUwc=" crossorigin="anonymous">
<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://cdn.jsdelivr.net/npm/hint.css@2.6.0/hint.min.css" integrity="sha256-UMhOZKeAbUSd/AoZKm+rlqzsBhzI7dTOYf2Euns4Es8=" crossorigin="anonymous">
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
body {
padding-top: 50px;
}
a {
color: inherit;
}
a:hover {
text-decoration: none;
}
.navbar-toggler {
cursor: pointer;
}
@media screen and (max-width: 767px) {
.hint--medium--xs:after {
white-space: normal;
@ -27,27 +27,53 @@
</style>
</head>
<body>
<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">
<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><a href="#sid{{ sid }}">Server {{ sid }}</a></li>
<li class="nav-item">
<a class="nav-link" 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>
</ul>
{% if debug %}
<span class="navbar-text" style="color: red">debug mode</span>
{% endif %}
</div>
</nav>
<div class="container-fluid">
{% for server in servers %}
<h1 class="page-header" id="sid{{ server.sid }}">
<h1 class="display-4" id="sid{{ server.sid }}">
<a href="#sid{{ server.sid }}">Server {{ server.sid }}</a>
</h1>
{% include 'stats.jinja2' %}
{% endfor %}
<small>Generated by <a href="https://github.com/Thor77/TeamspeakStats" rel="noopener">TeamspeakStats</a> at {{ creation_time|frmttime }}</small>
</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>
</html>

View file

@ -9,12 +9,12 @@
{% if clients|length > 0 %}
{% set headline_id = [server.sid, headline|lower|replace(' ', '_')]|join('.') %}
<h2><a href="#{{ headline_id }}">{{ headline }}</a></h2>
<ul class="list-group" id="{{ headline_id }}">
<ul class="list-group my-3" id="{{ headline_id }}">
{% for client, value in clients %}
{% set id = [headline_id, client.nick|striptags]|join('.') %}
<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" data-hint="{{ client.nick_history|join(', ') }}"><a href="#{{ id }}">{{ client.nick }}{{ " (" + client.identifier + ")" if debug }}</a></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 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;', '') }}">
<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="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>
</li>
{% endfor %}
</ul>

View file

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

14
tsstats/tests/conftest.py Normal file
View file

@ -0,0 +1,14 @@
# -*- 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

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

View file

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

View file

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

View file

@ -1,20 +1,19 @@
from datetime import datetime, timedelta
from os import remove
from time import sleep
# -*- coding: utf-8 -*-
import pendulum
import pytest
from tsstats.exceptions import InvalidLog
from tsstats.log import TimedLog, _bundle_logs, _parse_details, parse_logs
from tsstats import events
from tsstats.log import TimedLog, _bundle_logs, _parse_line, parse_logs
from tsstats.template import render_servers
from tsstats.tests.test_template import output_path
testlog_path = 'tsstats/tests/res/test.log'
static_timestamp = pendulum.datetime(2015, 5, 18, 15, 52, 52, 685612)
@pytest.fixture
def clients():
return _parse_details(testlog_path, online_dc=False)
return list(parse_logs(testlog_path, online_dc=False))[0].clients
def test_log_client_count(clients):
@ -22,8 +21,10 @@ def test_log_client_count(clients):
def test_log_onlinetime(clients):
assert clients['1'].onlinetime == timedelta(0, 402, 149208)
assert clients['2'].onlinetime == timedelta(0, 19, 759644)
assert clients['1'].onlinetime == pendulum.duration(
seconds=402, microseconds=149208)
assert clients['2'].onlinetime == pendulum.duration(
seconds=19, microseconds=759644)
def test_log_kicks(clients):
@ -54,12 +55,20 @@ def test_log_pbans(clients):
],
{
'1': [
TimedLog('ts3server_2016-06-06__14_22_09.527229_1.log',
datetime(year=2016, month=6, day=6, hour=14,
minute=22, second=9, microsecond=527229)),
TimedLog('ts3server_2017-07-07__15_23_10.638340_1.log',
datetime(year=2017, month=7, day=7, hour=15,
minute=23, second=10, microsecond=638340))
TimedLog(
'ts3server_2016-06-06__14_22_09.527229_1.log',
pendulum.datetime(
year=2016, month=6, day=6, hour=14, minute=22,
second=9, microsecond=527229
)
),
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
)
)
]
}
)
@ -68,37 +77,68 @@ def test_log_bundle(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():
clients = _parse_details(testlog_path)
current_time = pendulum.now()
pendulum.set_test_now(current_time)
clients = list(parse_logs(testlog_path))[0].clients
old_onlinetime = int(clients['1'].onlinetime.total_seconds())
sleep(2)
clients = _parse_details(testlog_path)
pendulum.set_test_now(current_time.add(seconds=2)) # add 2s to .now()
clients = list(parse_logs(testlog_path))[0].clients
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():
clients = _parse_details('tsstats/tests/res/test.log.groups')
assert len(clients) == 0
server = list(parse_logs('tsstats/tests/res/test.log.groups'))
assert len(server) == 0
def test_parse_utf8():
def test_parse_utf8(output):
servers = parse_logs(testlog_path + '.utf8')
render_servers(servers, output_path)
remove(output_path)
render_servers(servers, output)
def test_server_stop():
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
def test_parse_invalid_line():
assert _parse_line('INVALID') == []
@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,41 +1,35 @@
# -*- coding: utf-8 -*-
import logging
from datetime import timedelta
from os import remove
import pendulum
import pytest
from bs4 import BeautifulSoup
from tsstats.log import Server, _parse_details
from tsstats.log import parse_logs
from tsstats.template import render_servers
from tsstats.utils import filter_threshold, seconds_to_text, sort_clients
output_path = 'tsstats/tests/res/output.html'
clients = _parse_details('tsstats/tests/res/test.log', online_dc=False)
servers = [Server(1, clients)]
servers = list(parse_logs('tsstats/tests/res/test.log', online_dc=False))
servers[0] = servers[0]._replace(sid=1) # add missing sid to server object
clients = servers[0].clients
logger = logging.getLogger('tsstats')
@pytest.fixture
def output(request):
def clean():
remove(output_path)
request.addfinalizer(clean)
@pytest.fixture
def soup(output):
render_servers(servers, output_path)
return BeautifulSoup(open(output_path), 'html.parser')
render_servers(servers, output)
return BeautifulSoup(open(output), 'html.parser')
def test_debug(output):
logger.setLevel(logging.DEBUG)
render_servers(servers, output_path)
render_servers(servers, output)
logger.setLevel(logging.INFO)
soup = BeautifulSoup(open(output_path), 'html.parser')
soup = BeautifulSoup(open(output), 'html.parser')
# check debug-label presence
assert soup.find_all(style='color: red; padding-right: 10px;')
assert soup.find('nav').find('div', id='main-nav').find('span').text \
== 'debug mode'
for client_item in soup.find('ul', id='1.onlinetime').find_all('li'):
nick = client_item.find('span').text
# check for right identifier
@ -53,8 +47,8 @@ def test_onlinetime(soup):
onlinetime = onlinetime.text
# find corresponding client-object
client = list(filter(
lambda c: c.nick == nick and c.onlinetime > timedelta(0),
clients
lambda c: c.nick == nick and c.onlinetime > pendulum.duration(),
clients.values()
))
# assert existence
assert client
@ -72,3 +66,16 @@ def test_filter_threshold():
assert len(filter_threshold(sorted_clients, -1)) == len(sorted_clients)
assert len(filter_threshold(sorted_clients, 20)) == 1
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,7 +1,4 @@
# -*- coding: utf-8 -*-
import datetime
def sort_clients(clients, key_l):
'''
sort `clients` by `key`
@ -16,7 +13,8 @@ def sort_clients(clients, key_l):
:rtype: list
'''
cl_data = [
(client, key_l(client)) for client in clients if key_l(client) > 0
(client, key_l(client)) for client in clients.values()
if key_l(client) > 0
]
return sorted(cl_data, key=lambda data: data[1], reverse=True)
@ -52,34 +50,6 @@ def filter_threshold(clients, threshold):
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):
'''
Transforms a list of client ID mappings from a more descriptive format