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

Compare commits

..

No commits in common. "master" and "v0.12.3" have entirely different histories.

50 changed files with 762 additions and 1675 deletions

View file

@ -8,4 +8,3 @@ exclude_lines =
omit =
tsstats/tests/*
tsstats/__main__.py
tsstats/logger.py

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

11
.gitignore vendored
View file

@ -1,11 +1,2 @@
__pycache__/
*.py[cod]
venv/
*.pyc
.cache/
build/
dist/
*.egg-info/
*.egg
docs/_build

25
.travis.yml Normal file
View file

@ -0,0 +1,25 @@
language: python
python:
- 2.7
- 3.5
matrix:
include:
- python: 2.7
install:
- pip install pyflakes
- pip install isort
script:
- pyflakes 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,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2017 Thor77
Copyright (c) 2016 Thor77
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

74
README.md Normal file
View file

@ -0,0 +1,74 @@
# TeamspeakStats [![Build Status](https://travis-ci.org/Thor77/TeamspeakStats.svg?branch=master)](https://travis-ci.org/Thor77/TeamspeakStats) [![Coverage Status](https://coveralls.io/repos/Thor77/TeamspeakStats/badge.svg?branch=master&service=github)](https://coveralls.io/github/Thor77/TeamspeakStats?branch=master) [![Code Health](https://landscape.io/github/Thor77/TeamspeakStats/master/landscape.svg?style=flat)](https://landscape.io/github/Thor77/TeamspeakStats/master) [![PyPI](https://img.shields.io/pypi/v/tsstats.svg)](https://pypi.python.org/pypi/tsstats) [![Documentation Status](https://readthedocs.org/projects/teamspeakstats/badge/?version=latest)](http://teamspeakstats.readthedocs.io/en/latest/?badge=latest)
A simple Teamspeak stat-generator - based on server-logs
![screenshot](screenshot.png)
# Installation
- Install the package via PyPi `pip install tsstats`
# Usage
- Create a config (see [Configuration](https://github.com/Thor77/TeamspeakStats#configuration))
- Run the script `tsstats [-h]`
# Tests
- Install testing-requirements `pip install -r testing_requirements.txt`
- Run `py.test tsstats/`
# CMD-Arguments
```
usage: tsstats [-h] [-c CONFIG] [--idmap IDMAP] [-l LOG] [-o OUTPUT] [-d]
[-nod]
A simple Teamspeak stats-generator - based on server-logs
optional arguments:
-h, --help show this help message and exit
-c CONFIG, --config CONFIG
path to config
--idmap IDMAP path to id_map
-l LOG, --log LOG path to your logfile(s)
-o OUTPUT, --output OUTPUT
path to the output-file
-d, --debug debug mode
-nod, --noonlinedc don't add connect until now to onlinetime
-t TEMPLATE, --template TEMPLATE
path to custom template
-dtf DATETIMEFORMAT, --datetimeformat DATETIMEFORMAT
format of date/time-values (datetime.strftime)
```
# Configuration
#### [General]
| Key | Description |
|-----|-------------|
| log | Path to TS3Server-logfile(s) (supports [globbing](https://docs.python.org/3/library/glob.html)) |
| output | Path to the location, where the generator will put the generated `.html`-file |
| idmap | Path to [IdentMap](http://teamspeakstats.readthedocs.io/en/latest/identmap.html) |
| debug | debug mode |
| onlinedc | Add timedelta from last-connect until now to onlinetime for connected clients |
| template | Path to a custom template file (relative from `tsstats/` or absolute) |
| datetimeformat | Format of date/time-values used for render-timestamp and last online (using [datetime.strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior)) |
## Example
```
[General]
log = /usr/local/bin/teamspeak-server/logs/ts3server*_1.log
output = /var/www/html/stats.html
```
# ID-Mapping
`id_map.json`
You can map multiple ID's to one (for example, when an user creates a new identity)
## Example
```json
{
"1": "2",
"3": "2"
}
```
The online-time of `1` and `3` will be added to the online-time of `2`
# TODO
- Localization

View file

@ -1,47 +0,0 @@
TeamspeakStats |Build Status| |Build status| |Coverage Status| |PyPI| |Documentation Status|
==========================================================================================================
A simple Teamspeak stat-generator - based solely on server-logs
|screenshot|
Installation
============
- Install the package via PyPi ``pip install tsstats``
- Clone this repo
``git clone https://github.com/Thor77/TeamspeakStats`` and install
with ``python setup.py install``
- Just use the package as is via ``python -m tsstats [-h]``
Usage
=====
- Run the script ``tsstats [-h]``
- Optionally create a config-file (see
`Configuration <https://teamspeakstats.readthedocs.io/en/latest/config.html>`__)
- The package works entirely off your Teamspeak server's logs, so that
no ServerQuery account is necessary
Example
=======
::
tsstats -l /var/log/teamspeak3-server/ -o /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/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
.. |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
:target: http://teamspeakstats.readthedocs.io/en/latest/?badge=latest

View file

@ -1,20 +1,192 @@
# Minimal makefile for Sphinx documentation
# Makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SPHINXPROJ = TeamspeakStats
SOURCEDIR = .
BUILDDIR = _build
PAPER =
BUILDDIR = build
# User-friendly check for sphinx-build
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
endif
# Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
@echo "Please use \`make <target>' where <target> is one of"
@echo " html to make standalone HTML files"
@echo " dirhtml to make HTML files named index.html in directories"
@echo " singlehtml to make a single large HTML file"
@echo " pickle to make pickle files"
@echo " json to make JSON files"
@echo " htmlhelp to make HTML files and a HTML help project"
@echo " qthelp to make HTML files and a qthelp project"
@echo " applehelp to make an Apple Help Book"
@echo " devhelp to make HTML files and a Devhelp project"
@echo " epub to make an epub"
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
@echo " latexpdf to make LaTeX files and run them through pdflatex"
@echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
@echo " text to make text files"
@echo " man to make manual pages"
@echo " texinfo to make Texinfo files"
@echo " info to make Texinfo files and run them through makeinfo"
@echo " gettext to make PO message catalogs"
@echo " changes to make an overview of all changed/added/deprecated items"
@echo " xml to make Docutils-native XML files"
@echo " pseudoxml to make pseudoxml-XML files for display purposes"
@echo " linkcheck to check all external links for integrity"
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
@echo " coverage to run coverage check of the documentation (if enabled)"
.PHONY: help Makefile
clean:
rm -rf $(BUILDDIR)/*
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
html:
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
singlehtml:
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
@echo
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
pickle:
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
@echo
@echo "Build finished; now you can process the pickle files."
json:
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
@echo
@echo "Build finished; now you can process the JSON files."
htmlhelp:
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
@echo
@echo "Build finished; now you can run HTML Help Workshop with the" \
".hhp project file in $(BUILDDIR)/htmlhelp."
qthelp:
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
@echo
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/TeamspeakStats.qhcp"
@echo "To view the help file:"
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/TeamspeakStats.qhc"
applehelp:
$(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
@echo
@echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
@echo "N.B. You won't be able to view it unless you put it in" \
"~/Library/Documentation/Help or install it in your application" \
"bundle."
devhelp:
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo
@echo "Build finished."
@echo "To view the help file:"
@echo "# mkdir -p $$HOME/.local/share/devhelp/TeamspeakStats"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/TeamspeakStats"
@echo "# devhelp"
epub:
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
latex:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
@echo "Run \`make' in that directory to run these through (pdf)latex" \
"(use \`make latexpdf' here to do that automatically)."
latexpdf:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through pdflatex..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
latexpdfja:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through platex and dvipdfmx..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
text:
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
@echo
@echo "Build finished. The text files are in $(BUILDDIR)/text."
man:
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
@echo
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
texinfo:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
@echo "Run \`make' in that directory to run these through makeinfo" \
"(use \`make info' here to do that automatically)."
info:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo "Running Texinfo files through makeinfo..."
make -C $(BUILDDIR)/texinfo info
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
gettext:
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
@echo
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
changes:
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
@echo
@echo "The overview file is in $(BUILDDIR)/changes."
linkcheck:
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
@echo
@echo "Link check complete; look for any errors in the above output " \
"or in $(BUILDDIR)/linkcheck/output.txt."
doctest:
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
@echo "Testing of doctests in the sources finished, look at the " \
"results in $(BUILDDIR)/doctest/output.txt."
coverage:
$(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
@echo "Testing of coverage in the sources finished, look at the " \
"results in $(BUILDDIR)/coverage/python.txt."
xml:
$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
@echo
@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
pseudoxml:
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
@echo
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."

View file

@ -1,30 +0,0 @@
Command Line Interface
======================
.. code-block:: console
$ tsstats --help
usage: tsstats [-h] [-c CONFIG] [--idmap IDMAP] [-l LOG] [-o OUTPUT] [-d]
[-ds] [-nod] [-t TEMPLATE] [-dtf DATETIMEFORMAT]
[-otth ONLINETIMETHRESHOLD]
A simple Teamspeak stats-generator, based solely on server-logs
optional arguments:
-h, --help show this help message and exit
-c CONFIG, --config CONFIG
path to config
--idmap IDMAP path to id_map
-l LOG, --log LOG path to your logfile(s). pass a directory to use all
logfiles inside it
-o OUTPUT, --output OUTPUT
path to the output-file
-d, --debug debug mode
-ds, --debugstdout write debug output to stdout
-nod, --noonlinedc don't add connect until now to onlinetime
-t TEMPLATE, --template TEMPLATE
path to custom template
-dtf DATETIMEFORMAT, --datetimeformat DATETIMEFORMAT
format of date/time-values (datetime.strftime)
-otth ONLINETIMETHRESHOLD, --onlinetimethreshold ONLINETIMETHRESHOLD
threshold for displaying onlinetime (in seconds)

View file

@ -1,40 +0,0 @@
Config
======
The configfile is using the .ini-format.
Currently all settings are read from the ``[General]``-section.
+---------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Key | Description |
+=====================+=================================================================================================================================================================================+
| log | Path to TS3Server-logfile(s) (supports `globbing <https://docs.python.org/3/library/glob.html>`__) |
+---------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| output | Path to the location, where the generator will put the generated .html-file |
+---------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| idmap | Path to `IdentMap <https://teamspeakstats.readthedocs.io/en/latest/identmap.html>`__ |
+---------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| debug | debug mode |
+---------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| onlinedc | Add timedelta from last-connect until now to onlinetime for connected clients |
+---------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| template | Path to a custom template file (relative from ``tsstats/`` or absolute) |
+---------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| datetimeformat | Format of date/time-values used for render-timestamp and last online (using `datetime.strftime <https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior>`__) |
+---------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| onlinetimethreshold | Clients with an onlinetime below that threshold (in seconds) are hidden in the onlinetime-section |
+---------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
Example
-------
``config.ini``
.. code-block:: ini
[General]
log = /usr/local/bin/teamspeak-server/logs/ts3server*_1.log
output = /var/www/html/stats.html
.. code-block:: console
$ tsstats -c config.ini

View file

@ -1,35 +0,0 @@
Development
===========
Contributing
------------
Contributions are very welcome!
Before developing a new (possibly breaking) feature, please open an Issue about it first
so we can discuss your idea and possible implementations.
Please read this document carefully before submitting your Pull Request to avoid failing CI tests.
Style
-----
Your contribution should pass `flake8 <https://flake8.readthedocs.io>`__
as well as `isort <https://github.com/timothycrosley/isort>`__.
Testing
-------
There are unit tests for all parts of the project built with `py.test <https://docs.pytest.org>`__.
Besides ``py.test`` tests require ``BeautifulSoup`` for template-testing.
Those requirements are listed in ``testing_requirements.txt``::
$ pip install -r requirements-dev.txt
$ py.test tsstats/tests/
Versioning
----------
TeamspeakStats uses `Semantic Versioning <http://semver.org/>`__.
Please don't bump versions in your Pull Requests, though, we will do that after merging.
Python Versions
---------------
To keep the tool accessible and maintainable at the same time at least ``Python 2.7`` is required,
so keep this in mind when using fancy new features from a recent Python version.

View file

@ -1,13 +0,0 @@
TeamspeakStats Documentation
============================
.. toctree::
:maxdepth: 2
cli
config
identmap
api
development
.. include:: ../README.rst

View file

@ -1,14 +1,14 @@
API
===
***
Log
---
===
.. automodule:: tsstats.log
:members:
Client
------
======
.. autoclass:: tsstats.client.Client
:members:
@ -21,24 +21,24 @@ Client
.. automethod:: tsstats.client.Clients.__iter__
Template
--------
========
.. automodule:: tsstats.template
:members:
Config
------
======
.. automodule:: tsstats.config
:members:
Exceptions
----------
==========
.. automodule:: tsstats.exceptions
:members:
Utils
-----
=====
.. automodule:: tsstats.utils
:members:

2
docs/source/cli.rst Normal file
View file

@ -0,0 +1,2 @@
Command-Line-Interface
**********************

View file

@ -1,7 +1,8 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# TeamspeakStats documentation build configuration file, created by
# sphinx-quickstart on Tue Apr 18 22:51:54 2017.
# sphinx-quickstart on Thu May 12 21:42:11 2016.
#
# This file is execfile()d with the current directory set to its
# containing dir.
@ -12,60 +13,50 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
import sys
import os
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('..'))
sys.path.insert(0, os.path.abspath('../..'))
# -- General configuration ------------------------------------------------
autodoc_member_order = 'bysource'
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.coverage',
'sphinx.ext.viewcode'
'sphinx.ext.viewcode',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
# The encoding of source files.
source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'TeamspeakStats'
copyright = u'2017, Thor77'
author = u'Thor77'
project = 'TeamspeakStats'
copyright = '2016, Thor77'
author = 'Thor77'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = u'1.4'
version = '0.8'
# The full version, including alpha/beta/rc tags.
release = u'1.4.1'
# suppres warning about nonlocal images
suppress_warnings = ['image.nonlocal_uri']
release = '0.8.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@ -76,8 +67,7 @@ language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
exclude_patterns = []
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
@ -90,57 +80,19 @@ todo_include_todos = False
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
if os.environ.get('READTHEDOCS') != 'True':
# we're not built by rtd => add rtd-theme
import sphinx_rtd_theme
html_theme = 'sphinx_rtd_theme'
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
# html_theme_options = {}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
# html_static_path = ['_static']
# -- Options for HTMLHelp output ------------------------------------------
html_theme = 'alabaster'
# Output file base name for HTML help builder.
htmlhelp_basename = 'TeamspeakStatsdoc'
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'TeamspeakStats.tex', u'TeamspeakStats Documentation',
u'Thor77', 'manual'),
(master_doc, 'TeamspeakStats.tex', 'TeamspeakStats Documentation',
'Thor77', 'manual'),
]
@ -149,7 +101,7 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'teamspeakstats', u'TeamspeakStats Documentation',
(master_doc, 'teamspeakstats', 'TeamspeakStats Documentation',
[author], 1)
]
@ -160,7 +112,7 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'TeamspeakStats', u'TeamspeakStats Documentation',
author, 'TeamspeakStats', 'One line description of project.',
'Miscellaneous'),
(master_doc, 'TeamspeakStats', 'TeamspeakStats Documentation',
author, 'TeamspeakStats', 'One line description of project.',
'Miscellaneous'),
]

2
docs/source/config.rst Normal file
View file

@ -0,0 +1,2 @@
Config
******

View file

@ -1,42 +1,20 @@
IdentMap
========
********
An IdentMap is used to map multiple (U)ID's of one client to one client.
This can be useful, if a user creates multiple identities and you want to summarize all actions from all identities.
To pass an IdentMap to TeamspeakStats, create your IdentMap as shown above and pass it to the cli::
tsstats --idmap <path to idmap.json>
TeamspeakStats' IdentMap-file is saved in json-format::
[
{
"primary_id": "1",
"alternate_ids": ["2", "3", "4"]
}
]
If you would pass this IdentMap to TeamspeakStats and your log would contain entries for clients with id's 1, 2, 3 and 4,
your output will just show data for one client (1).
The format is flexible enough to support other arbitrary data to assist you in maintaining your IdentMap::
[
{
"name": "Friend 1",
"primary_id": "1",
"alternate_ids": ["2", "3", "4"]
}
]
The parser will ignore all nodes other than the "primary_id" and "alternate_ids" nodes, allowing you to use them for whatever you want.
The original IdentMap format is still supported::
{
'2': '1',
'3': '1',
'4': '1'
}
While it is less expressive, it is also less verbose.
If you would pass this IdentMap to TeamspeakStats and your log would contain entries for clients with id's 1, 2, 3 and 4,
your output will just show data for one client (1).
To pass an IdentMap to TeamspeakStats, create your IdentMap as shown above and pass it to the cli::
tsstats --idmap <path to idmap.json>

16
docs/source/index.rst Normal file
View file

@ -0,0 +1,16 @@
Welcome to TeamspeakStats's documentation!
==========================================
This document contains a more in-depth and technical documentation (in contrast to the `README <https://github.com/Thor77/TeamspeakStats/blob/master/README.md>`_)
for TeamspeakStats.
Contents:
.. toctree::
:maxdepth: 2
quickstart
cli
config
identmap
api

View file

@ -0,0 +1,31 @@
Quickstart
**********
First, you have to get and install a decent version of `Python <https://python.org/>`_ (at least 2.7).
Now you have various options to use TeamspeakStats, going from easy to hard:
1. Install the tool via pip ``pip install tsstats``
2. Clone the sourcecode ``git clone https://github.com/Thor77/TeamspeakStats``
* **A** Just run the script from your local copy ``python -m tsstats``
* **B** Install the script with the included *setup.py* ``python setup.py install``
To start, you can just use cli-arguments to control the behaviour of TeamspeakStats::
usage: tsstats [-h] [-c CONFIG] [--idmap IDMAP] [-l LOG] [-o OUTPUT] [-d] [-nod]
A simple Teamspeak stats-generator - based on server-logs
optional arguments:
-h, --help show this help message and exit
-c CONFIG, --config CONFIG
path to config
--idmap IDMAP path to id_map
-l LOG, --log LOG path to your logfile(s)
-o OUTPUT, --output OUTPUT
path to the output-file
-d, --debug debug mode
-nod, --noonlinedc don't add connect until now to onlinetime
Take a look at :doc:`cli` to get a more in-depth explanation of the available flags.
If you want to use TeamspeakStats in a script (or cron) you should consider creating
a :doc:`config`.

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

Binary file not shown.

Before

(image error) Size: 33 KiB

After

(image error) Size: 32 KiB

23
setup.py Normal file
View file

@ -0,0 +1,23 @@
from setuptools import setup
setup(
name='tsstats',
version='0.12.3',
author='Thor77',
author_email='thor77@thor77.org',
description='A simple Teamspeak stats-generator',
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': ['template.html']
},
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 +1,15 @@
# -*- coding: utf-8 -*-
import logging
logger = logging.getLogger('tsstats')
logger.setLevel(logging.INFO)
fh = logging.FileHandler('debug.txt', 'w', delay=True)
fh.setLevel(logging.DEBUG)
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
logger.addHandler(fh)
logger.addHandler(ch)

View file

@ -1,25 +1,21 @@
# -*- coding: utf-8 -*-
import argparse
import json
import logging
from os.path import join as pathjoin
from os.path import abspath, exists, isdir
from time import time
from os.path import abspath, exists
from tsstats import config
from tsstats.exceptions import InvalidConfiguration
from tsstats.log import parse_logs
from tsstats.logger import file_handler, stream_handler
from tsstats.template import render_servers
from tsstats.utils import transform_pretty_identmap
from tsstats.template import render_template
logger = logging.getLogger('tsstats')
def cli():
parser = argparse.ArgumentParser(
description='A simple Teamspeak stats-generator,'
' based solely on server-logs',
description='A simple Teamspeak stats-generator, based on server-logs',
argument_default=argparse.SUPPRESS
)
parser.add_argument(
@ -31,8 +27,7 @@ def cli():
)
parser.add_argument(
'-l', '--log',
type=str, help='path to your logfile(s). '
'pass a directory to use all logfiles inside it'
type=str, help='path to your logfile(s)'
)
parser.add_argument(
'-o', '--output',
@ -42,10 +37,6 @@ def cli():
'-d', '--debug',
help='debug mode', action='store_true'
)
parser.add_argument(
'-ds', '--debugstdout',
help='write debug output to stdout', action='store_true'
)
parser.add_argument(
'-nod', '--noonlinedc',
help='don\'t add connect until now to onlinetime',
@ -59,15 +50,6 @@ def cli():
'-dtf', '--datetimeformat',
type=str, help='format of date/time-values (datetime.strftime)'
)
parser.add_argument(
'-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)
@ -79,17 +61,8 @@ def cli():
def main(configuration):
start_time = time()
# setup logging
if configuration.getboolean('General', 'debug'):
logger.setLevel(logging.DEBUG)
if configuration.getboolean('General', 'debugstdout'):
stream_handler.setLevel(logging.DEBUG)
else:
logger.addHandler(file_handler)
# attach handlers
logger.addHandler(stream_handler)
idmap = configuration.get('General', 'idmap')
if idmap:
@ -100,32 +73,26 @@ def main(configuration):
identmap = json.load(open(idmap))
else:
identmap = None
if isinstance(identmap, list):
identmap = transform_pretty_identmap(identmap)
log = configuration.get('General', 'log')
if not log:
raise InvalidConfiguration('log or output missing')
if isdir(log):
log = pathjoin(log, '*.log')
servers = parse_logs(
sid_clients = parse_logs(
log, ident_map=identmap,
online_dc=configuration.getboolean('General', 'onlinedc')
)
render_servers(
sorted(servers, key=lambda s: s.sid),
output=abspath(configuration.get('General', 'output')),
template=configuration.get('General', 'template'),
datetime_fmt=configuration.get('General', 'datetimeformat'),
onlinetime_threshold=int(configuration.get(
'General', 'onlinetimethreshold'
)),
lastseen_relative=configuration.getboolean(
'General', 'lastseenrelative'
for sid, clients in sid_clients.items():
if sid and len(sid_clients) > 1:
ext = '.{}'.format(sid)
else:
ext = ''
render_template(
clients,
output=abspath(configuration.get('General', 'output') + ext),
template_path=configuration.get('General', 'template'),
datetime_fmt=configuration.get('General', 'datetimeformat')
)
)
logger.info('Finished after %s seconds', time() - start_time)
if __name__ == '__main__':

View file

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

View file

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
try:
from configparser import RawConfigParser
except ImportError:
@ -12,15 +13,12 @@ logger = logging.getLogger('tsstats')
DEFAULT_CONFIG = {
'General': {
'debug': False,
'debugstdout': False,
'log': '',
'output': 'tsstats.html',
'idmap': '',
'onlinedc': True,
'template': 'index.jinja2',
'datetimeformat': '%x %X %Z',
'onlinetimethreshold': -1,
'lastseenrelative': True
'template': 'template.html',
'datetimeformat': '%x %X %Z'
}
}

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 -*-
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,40 +1,64 @@
# -*- 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
import pendulum
from tsstats import events
from tsstats.client import Clients
from tsstats.client import Client, Clients
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')
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>.*)')
'__(?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>.*)')
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, *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: dict
'''
vserver_clients = {}
for virtualserver_id, logs in\
_bundle_logs(log_file for log_file in glob(log_glob)).items():
clients = Clients(ident_map)
for log in logs:
_parse_details(log.path, clients=clients, *args, **kwargs)
if len(clients) >= 1:
vserver_clients[virtualserver_id] = clients
return vserver_clients
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
@ -46,9 +70,9 @@ def _bundle_logs(logs):
match = re_log_filename.match(basename(log))
if match:
match = match.groupdict()
timestamp = pendulum.parse('{0} {1}'.format(
match['date'], match['time'].replace('_', ':'))
)
timestamp = datetime.strptime('{0} {1}'.format(
match['date'], match['time'].replace('_', ':')),
log_timestamp_format)
tl = TimedLog(log, timestamp)
sid = match['sid']
if sid in vserver_logfiles:
@ -59,7 +83,7 @@ def _bundle_logs(logs):
key=lambda tl: tl.timestamp)
else:
# if not exists, just create a list
vserver_logfiles[sid] = [tl]
vserver_logfiles[match['sid']] = [tl]
else:
# fallback to plain sorting
vserver_logfiles.setdefault('', [])\
@ -70,117 +94,67 @@ def _bundle_logs(logs):
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
:type line: str
detailed parsing is done here: onlinetime, kicks, pkicks, bans, pbans
:return: parsed events
:rtype list
'''
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('Unsupported client action: "%s"', message)
return []
nick, clid = match.group('nick'), match.group('clid')
: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
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 log_path: str
:type ident_map: dict
:type clients: tsstats.client.Clients
:type online_cd: bool
:return: list of servers
:rtype: [tsstats.log.Server]
:return: parsed clients
:rtype: tsstats.client.Clients
'''
for virtualserver_id, logs in _bundle_logs(glob(log_glob)).items():
if clients is None:
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
log_file = open(log_path)
# process lines
logger.debug('Started parsing of %s', log_file.name)
for line in log_file:
match = re_log_entry.match(line)
if not match:
logger.debug('No match: "%s"', line)
continue
match = match.groupdict()
logdatetime = 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(clid, nick))
client.nick = nick # set nick to display changes
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)
else:
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)
invoker.kick(client)
if online_dc:
for client in clients:
if client.connected:
client.disconnect(datetime.utcnow())
client.connected += 1
logger.debug('Finished parsing of %s', log_file.name)
return clients

View file

@ -1,13 +0,0 @@
# -*- coding: utf-8 -*-
import logging
# setup logger
logger = logging.getLogger('tsstats')
logger.setLevel(logging.INFO)
# define handlers
file_handler = logging.FileHandler('debug.txt', 'w', delay=True)
file_handler.setLevel(logging.DEBUG)
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.INFO)

39
tsstats/template.html Normal file
View file

@ -0,0 +1,39 @@
<html>
<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.0/hint.min.css" integrity="sha256-8tIlfXMO4Ui7kMz0olkEGYX8yNma7oowG+SZwnV+tYs=" crossorigin="anonymous">
<style type="text/css">
h1, p {
text-align: center;
}
</style>
</head>
<body>
<div class="container-fluid">
{% if debug %}
<div class="alert alert-danger" role="alert" style="text-align: center;">
<b>DEBUG</b>
</div>
{% endif %}
<h1 class="page-header">{{ title }}</h1>
{% for headline, list in objs %}
{% if list|length > 0 %}
{% set headline_id = headline|lower|replace(' ', '_') %}
<h2>{{ headline }}</h2>
<ul class="list-group" id="{{ headline_id }}">
{% for client, value in list %}
{% set id = headline_id + "." + client.nick %}
<li id="{{ id }}" onclick="window.location = '#{{ id }}'" class="list-group-item{{ ' list-group-item-success' if client.connected else loop.cycle('" style="background-color: #eee;', '') }}">
<span {% if not client.connected %}class="hint--right" data-hint="{{ client.last_seen|frmttime }}"{% endif %}>{{ client.nick }}{{ " (" + client.identifier + ")" if debug }}</span>
<span class="badge">{{ value }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
<small>Generated by <a href="https://github.com/Thor77/TeamspeakStats">TeamspeakStats</a> at {{ creation_time|frmttime }}</small>
</div>
</body>
</html>

View file

@ -1,89 +1,56 @@
# -*- coding: utf-8 -*-
import logging
from collections import namedtuple
from os.path import dirname, join
import pendulum
import logging
from datetime import datetime
from os.path import dirname
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PackageLoader
from tsstats.log import Server
from tsstats.utils import filter_threshold, seconds_to_text, sort_clients
from tsstats.utils import seconds_to_text, sort_clients
logger = logging.getLogger('tsstats')
SortedClients = namedtuple('SortedClients', [
'onlinetime', 'kicks', 'pkicks', 'bans', 'pbans'])
def prepare_clients(clients, onlinetime_threshold=-1):
def render_template(clients, output, title='TeamspeakStats',
template_path='template.html', datetime_fmt='%x %X %Z'):
'''
Prepare `clients` for rendering
render template with `clients`
sort them, clean their nick-history and convert onlinetime to string
:param clients: List of clients to prepare
:param onlinetime_threshold: threshold for clients onlinetime
:type clients: tsstats.client.Clients
:type onlinetime_treshold: int
:return: `clients` sorted by onlinetime, kics, pkicks, bans and pbans
:rtype: tsstats.template.SortedClients
'''
# sort by onlinetime
onlinetime_ = sort_clients(
clients, lambda c: c.onlinetime.total_seconds()
)
# filter clients not matching threshold
onlinetime_ = filter_threshold(onlinetime_, onlinetime_threshold)
# convert timespans to text
onlinetime = [
(client, seconds_to_text(int(onlinetime)))
for client, onlinetime in onlinetime_
]
return SortedClients(
onlinetime=onlinetime,
kicks=sort_clients(clients, lambda c: c.kicks),
pkicks=sort_clients(clients, lambda c: c.pkicks),
bans=sort_clients(clients, lambda c: c.bans),
pbans=sort_clients(clients, lambda c: c.pbans)
)
def render_servers(servers, output, title='TeamspeakStats',
template='index.jinja2', datetime_fmt='%x %X %Z',
onlinetime_threshold=-1, lastseen_relative=True):
'''
Render `servers`
:param servers: list of servers to render
:param clients: clients to fill template with
:param output: path to output-file
:param template_name: path to template-file
:param title: title of the resulting html-document
: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]
:type clients: tsstats.client.Clients
:type output: str
:type template_name: str
:type title: str
:type template_path: str
:type datetime_fmt: str
:type onlinetime_threshold: int
:type lastseen_relative: bool
'''
# preparse servers
prepared_servers = [
Server(sid, prepare_clients(clients, onlinetime_threshold))
for sid, clients in servers
# prepare clients
clients_onlinetime_ = sort_clients(
clients, lambda c: c.onlinetime.total_seconds()
)
clients_onlinetime = [
(client, seconds_to_text(int(onlinetime)))
for client, onlinetime in clients_onlinetime_
]
clients_kicks = sort_clients(clients, lambda c: c.kicks)
clients_pkicks = sort_clients(clients, lambda c: c.pkicks)
clients_bans = sort_clients(clients, lambda c: c.bans)
clients_pbans = sort_clients(clients, lambda c: c.pbans)
objs = [('Onlinetime', clients_onlinetime), ('Kicks', clients_kicks),
('passive Kicks', clients_pkicks),
('Bans', clients_bans), ('passive Bans', clients_pbans)]
# render
template_loader = ChoiceLoader([
PackageLoader(__package__, 'templates'),
FileSystemLoader(join(dirname(__file__), 'templates'))
PackageLoader(__package__, ''),
FileSystemLoader(dirname(__file__))
])
template_env = Environment(loader=template_loader)
@ -93,18 +60,11 @@ 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)
template = template_env.get_template(template_path)
logger.debug('Rendering template %s', template)
template.stream(title=title, servers=prepared_servers,
debug=logger.level <= logging.DEBUG,
creation_time=pendulum.now('UTC'))\
.dump(output, encoding='utf-8')
logger.debug('Wrote rendered template to %s', output)
with open(output, 'w') as f:
f.write(template.render(title=title, objs=objs,
debug=logger.level <= logging.DEBUG,
creation_time=datetime.now()))
logger.debug('Wrote rendered template to %s', output)

View file

@ -1,79 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
<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://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">
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;
line-height: 1.4em;
word-wrap: break-word;
}
}
</style>
</head>
<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">
{% for server in servers %}
<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

@ -1,22 +0,0 @@
{% set clients = server.clients %}
{% for headline, clients in [
('Onlinetime', clients.onlinetime),
('Kicks', clients.kicks),
('Passive kicks', clients.pkicks),
('Bans', clients.bans),
('Passive bans', clients.pbans)
] %}
{% 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 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 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>
{% endif %}
{% endfor %}

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,7 +0,0 @@
2017-03-28 01:00:00.000000|INFO |VirtualServer| 1| listening on 0.0.0.0:9987
2017-03-28 03:00:00.000000|INFO |VirtualServerBase| 2| client connected 'Client1'(id:2) from 1.2.3.4:1234
2017-03-28 04:00:00.000000|INFO |VirtualServerBase| 2| client disconnected 'Client1'(id:2) reason 'reasonmsg=ByeBye!'
2017-03-28 01:00:00.000000|INFO |VirtualServerBase| 2| client connected 'Client1'(id:1) from 1.2.3.4:1234
2017-03-28 02:00:00.000000|INFO |VirtualServerBase| 2| client disconnected 'Client1'(id:1) reason 'reasonmsg=ByeBye!'
2017-03-28 05:00:00.000000|INFO |VirtualServerBase| 2| client connected 'Client1'(id:3) from 1.2.3.4:1234
2017-03-28 06:00:00.000000|INFO |VirtualServerBase| 2| client disconnected 'Client1'(id:3) reason 'reasonmsg=ByeBye!'

View file

@ -1,4 +0,0 @@
2015-05-18 15:00:00.000000|INFO |VirtualServer| 1| listening on 0.0.0.0:9987
2015-05-18 15:30:00.000000|INFO |VirtualServerBase| 3| client connected 'Client1'(id:1) from 1.2.3.4:1234
2015-05-18 15:40:00.000000|INFO |VirtualServerBase| 3| client connected 'Client2'(id:2) from 5.6.7.8:5678
2015-05-18 15:50:00.000000|INFO |VirtualServerBase| 1| stopped

View file

@ -1,2 +0,0 @@
2015-05-18 15:55:23.456679|INFO |VirtualServerBase| 3| client connected 'Cläönt1'(id:1) from 1.2.3.4:1234
2015-05-18 16:00:14.951191|INFO |VirtualServerBase| 3| client disconnected 'Cläönt1'(id:1) reason 'reasonmsg=ByeBye!'

View file

@ -1,25 +1,19 @@
# -*- coding: utf-8 -*-
import pytest
from tsstats.client import Client, Clients
@pytest.fixture(scope='module')
def clients():
clients = Clients()
cl1 = Client('1')
cl2 = Client('2')
clients += cl1
clients += cl2
uidcl1 = Client('UID1')
uidcl2 = Client('UID2')
clients += uidcl1
clients += uidcl2
return (clients, cl1, cl2, uidcl1, uidcl2)
clients = Clients()
cl1 = Client('1')
cl2 = Client('2')
clients += cl1
clients += cl2
uidcl1 = Client('UID1')
uidcl2 = Client('UID2')
clients += uidcl1
clients += uidcl2
def test_client_get(clients):
clients, cl1, cl2, uidcl1, uidcl2 = clients
def test_client_get():
assert clients['1'] == cl1
assert clients['2'] == cl2
assert clients['UID1'] == uidcl1
@ -29,37 +23,21 @@ def test_client_get(clients):
clients['UID3']
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 repr(clients['1']) == str(clients['1'])
def test_client_repr():
assert str(clients['1']) == '<1,None>'
assert str(clients['2']) == '<2,None>'
assert str(clients['UID1']) == '<UID1,None>'
assert str(clients['UID2']) == '<UID2,None>'
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(clients.values())
def test_clients_iter():
client_list = list(iter(clients))
assert cl1 in client_list
assert cl2 in client_list
assert uidcl1 in client_list
assert uidcl2 in client_list
def test_clients_delete(clients):
clients, cl1, _, _, _ = clients
def test_clients_delete():
del clients['1']
assert '1' not in clients
assert cl1 not in clients

View file

@ -1,30 +1,47 @@
# -*- coding: utf-8 -*-
try:
from configparser import ConfigParser
except ImportError:
from ConfigParser import ConfigParser
from os import remove
from os.path import abspath, exists
import pytest
from tsstats.config import load
configpath = abspath('tsstats/tests/res/test.cfg')
def create_config(values, key='General'):
config = ConfigParser()
config.add_section('General')
for option, value in values.items():
config.set('General', option, value)
with open(configpath, 'w') as configfile:
config.write(configfile)
@pytest.fixture
def config():
return load()
def config(request):
def clean():
if exists(configpath):
remove(configpath)
request.addfinalizer(clean)
def test_config(config):
assert not config.getboolean('General', 'debug')
assert config.getboolean('General', 'onlinedc')
config.set('General', 'idmap', 'tsstats/tests/res/id_map.json')
assert config.get('General', 'idmap') ==\
create_config({
'idmap': 'tsstats/tests/res/id_map.json',
'log': 'tsstats/tests/res/test.log',
'output': 'output.html',
'debug': 'true',
'onlinedc': 'false'
})
configuration = load(configpath)
assert configuration.get('General', 'idmap') ==\
'tsstats/tests/res/id_map.json'
config.set('General', 'log', 'tsstats/tests/res/test.log')
assert config.get('General', 'log') == 'tsstats/tests/res/test.log'
config.set('General', 'output', 'output.html')
assert config.get('General', 'output') == 'output.html'
def test_read():
config = load(path='tsstats/tests/res/config.ini')
# test defaults
assert not config.getboolean('General', 'debug')
# test written values
assert config.get('General', 'log') == 'tsstats/tests/res/test.log'
assert config.get('General', 'output') == 'tsstats/tests/res/output.html'
assert configuration.get('General', 'log') == 'tsstats/tests/res/test.log'
assert configuration.get('General', 'output') == 'output.html'
assert configuration.getboolean('General', 'debug') is True
assert configuration.getboolean('General', 'onlinedc') is False

View file

@ -1,69 +1,20 @@
# -*- coding: utf-8 -*-
import pytest
from tsstats.client import Client, Clients
from tsstats.log import parse_logs
from tsstats.utils import transform_pretty_identmap
ident_map = {
'1': '2',
'5': '2',
'UID1': 'UID2',
'UID5': 'UID2'
}
clients = Clients(ident_map)
cl = Client('2', 'Client2')
uidcl = Client('UID2', 'Client2++')
clients += cl
clients += uidcl
@pytest.fixture(scope='module')
def identmap_clients():
clients = Clients({
'1': '2',
'5': '2',
'UID1': 'UID2',
'UID5': 'UID2'
})
cl = Client('2', 'Client2')
uidcl = Client('UID2', 'Client2++')
clients += cl
clients += uidcl
return (clients, cl, uidcl)
def test_ident_map(identmap_clients):
clients, cl, uidcl = identmap_clients
def test_ident_map():
assert clients['1'] == cl
assert clients['5'] == cl
assert clients['UID1'] == uidcl
assert clients['UID5'] == uidcl
@pytest.mark.parametrize('test_input,expected', [
(
[
{'primary_id': '1', 'alternate_ids': ['3', '6']},
{'primary_id': '4', 'alternate_ids': ['9', '42', '23']}
],
(('3', '1'), ('6', '1'), ('9', '4'), ('42', '4'), ('23', '4'))
),
(
[
{'name': 'Friend 1', 'primary_id': '2', 'alternate_ids': ['4']},
{
'name': 'Friend 3',
'primary_id': '8',
'alternate_ids': ['9', '14']
}
],
(('4', '2'), ('9', '8'), ('14', '8'))
)
])
def test_transform_pretty_identmap(test_input, expected):
transformed_identmap = transform_pretty_identmap(test_input)
for alternate, primary in expected:
assert transformed_identmap[alternate] == primary
def test_ident_map_wrong_identifier():
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
# assert correct identifier
assert client.identifier == '1'

View file

@ -1,19 +1,17 @@
# -*- coding: utf-8 -*-
import pendulum
from datetime import datetime, timedelta
from time import sleep
import pytest
from tsstats import events
from tsstats.log import TimedLog, _bundle_logs, _parse_line, parse_logs
from tsstats.template import render_servers
from tsstats.exceptions import InvalidLog
from tsstats.log import TimedLog, _bundle_logs, _parse_details, parse_logs
testlog_path = 'tsstats/tests/res/test.log'
static_timestamp = pendulum.datetime(2015, 5, 18, 15, 52, 52, 685612)
@pytest.fixture
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):
@ -21,10 +19,8 @@ def test_log_client_count(clients):
def test_log_onlinetime(clients):
assert clients['1'].onlinetime == pendulum.duration(
seconds=402, microseconds=149208)
assert clients['2'].onlinetime == pendulum.duration(
seconds=19, microseconds=759644)
assert clients['1'].onlinetime == timedelta(0, 402, 149208)
assert clients['2'].onlinetime == timedelta(0, 19, 759644)
def test_log_kicks(clients):
@ -55,20 +51,12 @@ def test_log_pbans(clients):
],
{
'1': [
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
)
)
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))
]
}
)
@ -77,68 +65,25 @@ 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():
current_time = pendulum.now()
pendulum.set_test_now(current_time)
clients = list(parse_logs(testlog_path))[0].clients
clients = _parse_details(testlog_path)
old_onlinetime = int(clients['1'].onlinetime.total_seconds())
pendulum.set_test_now(current_time.add(seconds=2)) # add 2s to .now()
clients = list(parse_logs(testlog_path))[0].clients
sleep(2)
clients = _parse_details(testlog_path)
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)[''])
def test_parse_groups():
server = list(parse_logs('tsstats/tests/res/test.log.groups'))
assert len(server) == 0
def test_parse_utf8(output):
servers = parse_logs(testlog_path + '.utf8')
render_servers(servers, output)
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
clients = _parse_details('tsstats/tests/res/test.log.groups')
assert len(clients) == 0

View file

@ -1,36 +1,41 @@
# -*- 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 parse_logs
from tsstats.template import render_servers
from tsstats.utils import filter_threshold, seconds_to_text, sort_clients
from tsstats.log import _parse_details
from tsstats.template import render_template
from tsstats.utils import seconds_to_text
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
output_path = 'tsstats/tests/res/output.html'
clients = _parse_details('tsstats/tests/res/test.log')
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)
return BeautifulSoup(open(output), 'html.parser')
render_template(clients, output_path)
return BeautifulSoup(open(output_path), 'html.parser')
def test_debug(output):
logger.setLevel(logging.DEBUG)
render_servers(servers, output)
render_template(clients, output_path)
logger.setLevel(logging.INFO)
soup = BeautifulSoup(open(output), 'html.parser')
soup = BeautifulSoup(open(output_path), 'html.parser')
# check debug-label presence
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'):
assert soup.find_all(class_='alert alert-danger')
for client_item in soup.find_all('li'):
nick = client_item.find('span').text
# check for right identifier
nick, encl_identifier = nick.split()
@ -39,43 +44,19 @@ def test_debug(output):
def test_onlinetime(soup):
items = soup.find('ul', id='1.onlinetime').find_all('li')
assert len(items) == 2
# move this into a (parameterized) fixture or function
items = soup.find('ul', id='onlinetime').find_all('li')
nick_data = {}
for item in items:
nick, onlinetime = item.find_all('span')
nick = nick.text
onlinetime = onlinetime.text
# find corresponding client-object
client = list(filter(
lambda c: c.nick == nick and c.onlinetime > pendulum.duration(),
clients.values()
))
# assert existence
assert client
client = client[0]
# compare onlinetimes
client_onlinetime_text = seconds_to_text(
int(client.onlinetime.total_seconds())
)
assert onlinetime == client_onlinetime_text
def test_filter_threshold():
sorted_clients = sort_clients(
clients, lambda c: c.onlinetime.total_seconds())
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'
nick, data = item.find_all('span')
nick_data[nick.text] = data.text
# seperate between uuid and id-clients or merge them some way
# => assert len(items) == len(clients.id)
assert len(items) == 2
for client in clients:
if client.nick in nick_data and client.onlinetime > timedelta(0):
# remove this clause after splitting cients
# (uuid-clients will never have a online-time, because
# they're only used for bans and kicks)
assert nick_data[client.nick] == \
seconds_to_text(int(client.onlinetime.total_seconds()))

View file

@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
def sort_clients(clients, key_l):
'''
sort `clients` by `key`
@ -13,8 +15,7 @@ def sort_clients(clients, key_l):
:rtype: list
'''
cl_data = [
(client, key_l(client)) for client in clients.values()
if key_l(client) > 0
(client, key_l(client)) for client in clients if key_l(client) > 0
]
return sorted(cl_data, key=lambda data: data[1], reverse=True)
@ -35,35 +36,3 @@ def seconds_to_text(seconds):
minutes = str(minutes) + 'm ' if minutes > 0 else ''
seconds = str(seconds) + 's' if seconds > 0 else ''
return hours + minutes + seconds
def filter_threshold(clients, threshold):
'''
Filter clients by threshold
:param clients: List of clients as returned by tsstats.utils.sort_clients
:type clients: list
:return: Clients matching given threshold
:rtype: list
'''
return list(filter(lambda c: c[1] > threshold, clients))
def transform_pretty_identmap(pretty_identmap):
'''
Transforms a list of client ID mappings from a more descriptive format
to the traditional format of alternative IDs to actual ID.
:param pretty_identmap: ID mapping in "nice" form
:type pretty_identmap: list
:return: ID mapping in simple key/value pairs
:rtype: dict
'''
final_identmap = {}
for mapping in pretty_identmap:
for alt_id in mapping['alternate_ids']:
final_identmap[alt_id] = mapping['primary_id']
return final_identmap