├── .gitignore ├── LICENSES └── MIT.txt ├── README.md ├── assets └── img │ └── example_dialog.png ├── poetry.lock ├── pyproject.toml ├── src └── aw_watcher_ask │ ├── __init__.py │ ├── __main__.py │ ├── cli.py │ ├── core.py │ ├── models.py │ └── utils.py └── tests ├── __init__.py ├── test_cli.py ├── test_core.py └── test_utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | 139 | # pytype static type analyzer 140 | .pytype/ 141 | 142 | # Cython debug symbols 143 | cython_debug/ 144 | 145 | # End of https://www.toptal.com/developers/gitignore/api/python 146 | 147 | # VSCode 148 | .vscode -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # aw-watcher-ask 8 | 9 | An [ActivityWatch][] watcher to pose questions to the user and record her 10 | answers. 11 | 12 | This watcher uses [Zenity][Zenity Manual] to present dialog boxes to the user, and stores her answers in a locally running instance of ActivityWatch. This can be useful to poll all sorts of information on a periodical or random basis. The inspiration comes from the [experience sampling method (ESM)](https://en.wikipedia.org/wiki/Experience_sampling_method) used in psychological studies, as well as from the [quantified self](https://en.wikipedia.org/wiki/Quantified_self) movement. 13 | 14 | [ActivityWatch]: https://activitywatch.readthedocs.io/en/latest/ 15 | 16 | ## Table of Contents 17 | 18 | - [aw-watcher-ask](#aw-watcher-ask) 19 | - [Table of Contents](#table-of-contents) 20 | - [Install](#install) 21 | - [Using `pip`/`pipx`](#using-pippipx) 22 | - [From source](#from-source) 23 | - [Usage](#usage) 24 | - [CLI](#cli) 25 | - [Accessing the data](#accessing-the-data) 26 | - [Security](#security) 27 | - [Limitations and Roadmap](#limitations-and-roadmap) 28 | - [Maintainers](#maintainers) 29 | - [Contributing](#contributing) 30 | - [License](#license) 31 | 32 | ## Install 33 | 34 | ### Using `pip`/`pipx` 35 | 36 | Create a [virtual environment][venv], activate it and run: 37 | 38 | ```sh 39 | $ python3 -m pip install git+https://github.com/bcbernardo/aw-watcher-ask.git 40 | Collecting git+https://github.com/bcbernardo/aw-watcher-ask.git 41 | ... ... 42 | Installing collected packages: aw-watcher-ask 43 | Successfully installed aw-watcher-ask-0.1.0 44 | ``` 45 | 46 | Alternatively, you may use [`pipx`][pipx] to abstract away the creation of the virtual environment, and make sure the package is globally available: 47 | 48 | ```sh 49 | $ pipx install git+https://github.com/bcbernardo/aw-watcher-ask.git 50 | installed package aw-watcher-ask 0.1.0, Python 3.9.6 51 | These apps are now globally available 52 | - aw-watcher-ask 53 | done! ✨ 🌟 ✨ 54 | ``` 55 | 56 | [venv]: https://docs.python.org/3/tutorial/venv.html 57 | [pipx]: https://pypa.github.io/pipx/ 58 | 59 | ### From source 60 | 61 | To install the watcher, clone the repository to your local filesystem and 62 | install it with [poetry](https://python-poetry.org/docs): 63 | 64 | ```sh 65 | $ git clone https://github.com/bcbernardo/aw-watcher-ask.git 66 | $ cd aw-watcher-ask 67 | $ poetry install 68 | ... ... 69 | Installing the current project: aw-watcher-ask (0.1.0) 70 | $ poetry shell # alternatively, add `poetry run` before every command in the examples below 71 | ``` 72 | 73 | ## Usage 74 | 75 | Before you start using `aw-watcher-ask`, make sure you have ActivityWatch [installed and running][AW installation]. 76 | 77 | [AW installation]: https://docs.activitywatch.net/en/latest/getting-started.html 78 | 79 | ### CLI 80 | 81 | The following command will show the dialog box below each hour at 00 minutes 82 | and 00 seconds, wait up to 120 seconds for the user's response, and save it to 83 | a bucket in the local ActivityWatcher instance. 84 | 85 | ```sh 86 | $ aw-watcher-ask run --question-id "happiness.level" --question-type="question" --title="My happiness level" --text="Are you feeling happy right now?" --timeout=120 --schedule "0 */1 * * * 0" 87 | ... ... 88 | ``` 89 | 90 | ![Example dialog asking if the user is happy](./assets/img/example_dialog.png) 91 | 92 | Check `aw-watcher-ask run --help` to see all required and optional control parameters. 93 | 94 | The `--question-id` is used to identify this particular question in the ActivityWatcher a `aw-watcher-ask` bucket, and is therefore mandatory. 95 | 96 | The `question-type` parameters is also required and should be one of Zenity's supported [dialog types][Zenity Manual] (complex types such as `forms`, `file-selection` and `list` have not been implemented yet). All options supported by these dialog types are accepted by `aw-watcher-ask run` as extra parameters, and passed unaltered to Zenity under the hood. 97 | 98 | [Zenity Manual]: https://help.gnome.org/users/zenity/stable/ 99 | 100 | ### Accessing the data 101 | 102 | All data gathered is stored under `aw-watcher-ask_localhost.localdomain` bucket (or `test-aw-watcher-ask_localhost.localdomain`, when running with the `--testing` flag) in the local ActivityWatch endpoint. Check ActivityWatch [REST API documentation][AW API] to learn how to get the stored events programatically, so that you can apply some custom analysis. 103 | 104 | [AW API]: https://docs.activitywatch.net/en/latest/api/rest.html 105 | 106 | ## Security 107 | 108 | As other ActivityWatcher [watchers][AW watchers], `aw-watcher-ask` communicates solely with the locally running AW server instance. All data collected is stored in your machine. 109 | 110 | [AW watchers]: https://docs.activitywatch.net/en/latest/watchers.html 111 | 112 | ## Limitations and Roadmap 113 | 114 | `aw-watcher-ask` is in a very early development stage. Expect bugs and strange behaviors when using it. 115 | 116 | This package uses `zenity` utility, which must be installed in the system and globally accessible through the command line. Zenity comes pre-installed with most Linux installations, and can be installed from all major package repositories (`apt`, `dnf`, `pacman`, `brew` etc.). 117 | 118 | Porting Zenity to Windows is not trivial. If you use Windows, you may give @ncruces' [Go port](https://github.com/ncruces/zenity) a shot, as it is supposed to be cross-platform. Instructions to install on Windows can be found [here](https://timing.rbind.io/post/2021-12-19-setting-up-zenity-with-windows-python-go/) 119 | 120 | `aw-watcher-ask` does not currently have a way of storing the questions made, and scheduling them every time the system restarts. We want to implement this eventually, but for now you should wrap all questions you want to schedule in a (shell) script and configure your system to execute it at every startup. 121 | 122 | ## Maintainers 123 | 124 | - Bernardo Chrispim Baron ([@bcbernardo](https://github.com/bcbernardo)) 125 | 126 | ## Contributing 127 | 128 | PRs accepted. Please [open an issue][new issue] if you have an idea for enhancement or have spotted a bug. 129 | 130 | [new issue]: https://github.com/bcbernardo/aw-watcher-ask/issues/new/choose 131 | 132 | ## License 133 | 134 | MIT License 135 | 136 | Copyright (c) 2021 Bernardo Chrispim Baron 137 | 138 | Permission is hereby granted, free of charge, to any person obtaining a copy 139 | of this software and associated documentation files (the "Software"), to deal 140 | in the Software without restriction, including without limitation the rights 141 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 142 | copies of the Software, and to permit persons to whom the Software is 143 | furnished to do so, subject to the following conditions: 144 | 145 | The above copyright notice and this permission notice shall be included in all 146 | copies or substantial portions of the Software. 147 | 148 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 149 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 150 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 151 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 152 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 153 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 154 | SOFTWARE. 155 | -------------------------------------------------------------------------------- /assets/img/example_dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcbernardo/aw-watcher-ask/7b09be0e28a3c3d9227af0782d756c48f9217191/assets/img/example_dialog.png -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "appdirs" 3 | version = "1.4.4" 4 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 5 | category = "main" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "atomicwrites" 11 | version = "1.4.0" 12 | description = "Atomic file writes." 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 16 | 17 | [[package]] 18 | name = "attrs" 19 | version = "21.2.0" 20 | description = "Classes Without Boilerplate" 21 | category = "main" 22 | optional = false 23 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 24 | 25 | [package.extras] 26 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] 27 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 28 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] 29 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] 30 | 31 | [[package]] 32 | name = "aw-client" 33 | version = "0.5.4" 34 | description = "Client library for ActivityWatch" 35 | category = "main" 36 | optional = false 37 | python-versions = ">=3.7,<4.0" 38 | 39 | [package.dependencies] 40 | aw-core = ">=0.5.1,<0.6.0" 41 | click = ">=7.1.1,<8.0.0" 42 | persist-queue = ">=0.6.0,<0.7.0" 43 | requests = ">=2.22.0,<3.0.0" 44 | 45 | [[package]] 46 | name = "aw-core" 47 | version = "0.5.4" 48 | description = "Core library for ActivityWatch" 49 | category = "main" 50 | optional = false 51 | python-versions = ">=3.7,<4.0" 52 | 53 | [package.dependencies] 54 | appdirs = ">=1.4.3,<2.0.0" 55 | deprecation = "*" 56 | iso8601 = ">=0.1.12,<0.2.0" 57 | jsonschema = ">=3.1,<4.0" 58 | peewee = ">=3.0.0,<4.0.0" 59 | python-json-logger = ">=0.1.11,<0.2.0" 60 | strict-rfc3339 = ">=0.7,<0.8" 61 | TakeTheTime = ">=0.3.1,<0.4.0" 62 | timeslot = "*" 63 | tomlkit = "*" 64 | 65 | [package.extras] 66 | mongo = ["pymongo (>=3.10.0,<4.0.0)"] 67 | 68 | [[package]] 69 | name = "certifi" 70 | version = "2021.5.30" 71 | description = "Python package for providing Mozilla's CA Bundle." 72 | category = "main" 73 | optional = false 74 | python-versions = "*" 75 | 76 | [[package]] 77 | name = "charset-normalizer" 78 | version = "2.0.4" 79 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 80 | category = "main" 81 | optional = false 82 | python-versions = ">=3.5.0" 83 | 84 | [package.extras] 85 | unicode_backport = ["unicodedata2"] 86 | 87 | [[package]] 88 | name = "click" 89 | version = "7.1.2" 90 | description = "Composable command line interface toolkit" 91 | category = "main" 92 | optional = false 93 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 94 | 95 | [[package]] 96 | name = "colorama" 97 | version = "0.4.4" 98 | description = "Cross-platform colored terminal text." 99 | category = "main" 100 | optional = false 101 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 102 | 103 | [[package]] 104 | name = "croniter" 105 | version = "1.0.15" 106 | description = "croniter provides iteration for datetime object with cron like format" 107 | category = "main" 108 | optional = false 109 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 110 | 111 | [package.dependencies] 112 | python-dateutil = "*" 113 | 114 | [[package]] 115 | name = "deprecation" 116 | version = "2.1.0" 117 | description = "A library to handle automated deprecations" 118 | category = "main" 119 | optional = false 120 | python-versions = "*" 121 | 122 | [package.dependencies] 123 | packaging = "*" 124 | 125 | [[package]] 126 | name = "idna" 127 | version = "3.2" 128 | description = "Internationalized Domain Names in Applications (IDNA)" 129 | category = "main" 130 | optional = false 131 | python-versions = ">=3.5" 132 | 133 | [[package]] 134 | name = "importlib-metadata" 135 | version = "4.6.3" 136 | description = "Read metadata from Python packages" 137 | category = "main" 138 | optional = false 139 | python-versions = ">=3.6" 140 | 141 | [package.dependencies] 142 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 143 | zipp = ">=0.5" 144 | 145 | [package.extras] 146 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 147 | perf = ["ipython"] 148 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] 149 | 150 | [[package]] 151 | name = "iso8601" 152 | version = "0.1.16" 153 | description = "Simple module to parse ISO 8601 dates" 154 | category = "main" 155 | optional = false 156 | python-versions = "*" 157 | 158 | [[package]] 159 | name = "jsonschema" 160 | version = "3.2.0" 161 | description = "An implementation of JSON Schema validation for Python" 162 | category = "main" 163 | optional = false 164 | python-versions = "*" 165 | 166 | [package.dependencies] 167 | attrs = ">=17.4.0" 168 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 169 | pyrsistent = ">=0.14.0" 170 | six = ">=1.11.0" 171 | 172 | [package.extras] 173 | format = ["idna", "jsonpointer (>1.13)", "rfc3987", "strict-rfc3339", "webcolors"] 174 | format_nongpl = ["idna", "jsonpointer (>1.13)", "webcolors", "rfc3986-validator (>0.1.0)", "rfc3339-validator"] 175 | 176 | [[package]] 177 | name = "loguru" 178 | version = "0.5.3" 179 | description = "Python logging made (stupidly) simple" 180 | category = "main" 181 | optional = false 182 | python-versions = ">=3.5" 183 | 184 | [package.dependencies] 185 | colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} 186 | win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} 187 | 188 | [package.extras] 189 | dev = ["codecov (>=2.0.15)", "colorama (>=0.3.4)", "flake8 (>=3.7.7)", "tox (>=3.9.0)", "tox-travis (>=0.12)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "Sphinx (>=2.2.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "black (>=19.10b0)", "isort (>=5.1.1)"] 190 | 191 | [[package]] 192 | name = "more-itertools" 193 | version = "8.8.0" 194 | description = "More routines for operating on iterables, beyond itertools" 195 | category = "dev" 196 | optional = false 197 | python-versions = ">=3.5" 198 | 199 | [[package]] 200 | name = "packaging" 201 | version = "21.0" 202 | description = "Core utilities for Python packages" 203 | category = "main" 204 | optional = false 205 | python-versions = ">=3.6" 206 | 207 | [package.dependencies] 208 | pyparsing = ">=2.0.2" 209 | 210 | [[package]] 211 | name = "peewee" 212 | version = "3.14.4" 213 | description = "a little orm" 214 | category = "main" 215 | optional = false 216 | python-versions = "*" 217 | 218 | [[package]] 219 | name = "persist-queue" 220 | version = "0.6.0" 221 | description = "A thread-safe disk based persistent queue in Python." 222 | category = "main" 223 | optional = false 224 | python-versions = "*" 225 | 226 | [package.extras] 227 | extra = ["msgpack (>=0.5.6)"] 228 | 229 | [[package]] 230 | name = "pluggy" 231 | version = "0.13.1" 232 | description = "plugin and hook calling mechanisms for python" 233 | category = "dev" 234 | optional = false 235 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 236 | 237 | [package.dependencies] 238 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 239 | 240 | [package.extras] 241 | dev = ["pre-commit", "tox"] 242 | 243 | [[package]] 244 | name = "py" 245 | version = "1.10.0" 246 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 247 | category = "dev" 248 | optional = false 249 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 250 | 251 | [[package]] 252 | name = "pyparsing" 253 | version = "2.4.7" 254 | description = "Python parsing module" 255 | category = "main" 256 | optional = false 257 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 258 | 259 | [[package]] 260 | name = "pyrsistent" 261 | version = "0.18.0" 262 | description = "Persistent/Functional/Immutable data structures" 263 | category = "main" 264 | optional = false 265 | python-versions = ">=3.6" 266 | 267 | [[package]] 268 | name = "pytest" 269 | version = "5.4.3" 270 | description = "pytest: simple powerful testing with Python" 271 | category = "dev" 272 | optional = false 273 | python-versions = ">=3.5" 274 | 275 | [package.dependencies] 276 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 277 | attrs = ">=17.4.0" 278 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 279 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 280 | more-itertools = ">=4.0.0" 281 | packaging = "*" 282 | pluggy = ">=0.12,<1.0" 283 | py = ">=1.5.0" 284 | wcwidth = "*" 285 | 286 | [package.extras] 287 | checkqa-mypy = ["mypy (==v0.761)"] 288 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 289 | 290 | [[package]] 291 | name = "python-dateutil" 292 | version = "2.8.2" 293 | description = "Extensions to the standard Python datetime module" 294 | category = "main" 295 | optional = false 296 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 297 | 298 | [package.dependencies] 299 | six = ">=1.5" 300 | 301 | [[package]] 302 | name = "python-json-logger" 303 | version = "0.1.11" 304 | description = "A python library adding a json log formatter" 305 | category = "main" 306 | optional = false 307 | python-versions = ">=2.7" 308 | 309 | [[package]] 310 | name = "pyzenity" 311 | version = "2.0.0" 312 | description = "lightweight and full featured library to display dialogs with python." 313 | category = "main" 314 | optional = false 315 | python-versions = "*" 316 | develop = false 317 | 318 | [package.source] 319 | type = "git" 320 | url = "https://github.com/bcbernardo/Zenity.git" 321 | reference = "ab46b78" 322 | resolved_reference = "ab46b78ba0dc93c84202c8e0772df3b547c85fe7" 323 | 324 | [[package]] 325 | name = "requests" 326 | version = "2.26.0" 327 | description = "Python HTTP for Humans." 328 | category = "main" 329 | optional = false 330 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 331 | 332 | [package.dependencies] 333 | certifi = ">=2017.4.17" 334 | charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} 335 | idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} 336 | urllib3 = ">=1.21.1,<1.27" 337 | 338 | [package.extras] 339 | socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] 340 | use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] 341 | 342 | [[package]] 343 | name = "six" 344 | version = "1.16.0" 345 | description = "Python 2 and 3 compatibility utilities" 346 | category = "main" 347 | optional = false 348 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 349 | 350 | [[package]] 351 | name = "strict-rfc3339" 352 | version = "0.7" 353 | description = "Strict, simple, lightweight RFC3339 functions" 354 | category = "main" 355 | optional = false 356 | python-versions = "*" 357 | 358 | [[package]] 359 | name = "takethetime" 360 | version = "0.3.1" 361 | description = "Take The Time, a time-taking library for Python" 362 | category = "main" 363 | optional = false 364 | python-versions = "*" 365 | 366 | [[package]] 367 | name = "timeout-decorator" 368 | version = "0.5.0" 369 | description = "Timeout decorator" 370 | category = "main" 371 | optional = false 372 | python-versions = "*" 373 | 374 | [[package]] 375 | name = "timeslot" 376 | version = "0.1.2" 377 | description = "Data type for representing time slots with a start and end." 378 | category = "main" 379 | optional = false 380 | python-versions = ">=3.6,<4.0" 381 | 382 | [[package]] 383 | name = "tomlkit" 384 | version = "0.7.2" 385 | description = "Style preserving TOML library" 386 | category = "main" 387 | optional = false 388 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 389 | 390 | [[package]] 391 | name = "typer" 392 | version = "0.3.2" 393 | description = "Typer, build great CLIs. Easy to code. Based on Python type hints." 394 | category = "main" 395 | optional = false 396 | python-versions = ">=3.6" 397 | 398 | [package.dependencies] 399 | click = ">=7.1.1,<7.2.0" 400 | 401 | [package.extras] 402 | test = ["pytest-xdist (>=1.32.0,<2.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "mypy (==0.782)", "black (>=19.10b0,<20.0b0)", "isort (>=5.0.6,<6.0.0)", "shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "coverage (>=5.2,<6.0)"] 403 | all = ["colorama (>=0.4.3,<0.5.0)", "shellingham (>=1.3.0,<2.0.0)"] 404 | dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)"] 405 | doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=5.4.0,<6.0.0)", "markdown-include (>=0.5.1,<0.6.0)"] 406 | 407 | [[package]] 408 | name = "typing-extensions" 409 | version = "3.10.0.0" 410 | description = "Backported and Experimental Type Hints for Python 3.5+" 411 | category = "main" 412 | optional = false 413 | python-versions = "*" 414 | 415 | [[package]] 416 | name = "unidecode" 417 | version = "1.2.0" 418 | description = "ASCII transliterations of Unicode text" 419 | category = "main" 420 | optional = false 421 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 422 | 423 | [[package]] 424 | name = "urllib3" 425 | version = "1.26.6" 426 | description = "HTTP library with thread-safe connection pooling, file post, and more." 427 | category = "main" 428 | optional = false 429 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 430 | 431 | [package.extras] 432 | brotli = ["brotlipy (>=0.6.0)"] 433 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 434 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 435 | 436 | [[package]] 437 | name = "wcwidth" 438 | version = "0.2.5" 439 | description = "Measures the displayed width of unicode strings in a terminal" 440 | category = "dev" 441 | optional = false 442 | python-versions = "*" 443 | 444 | [[package]] 445 | name = "win32-setctime" 446 | version = "1.0.3" 447 | description = "A small Python utility to set file creation time on Windows" 448 | category = "main" 449 | optional = false 450 | python-versions = ">=3.5" 451 | 452 | [package.extras] 453 | dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"] 454 | 455 | [[package]] 456 | name = "zipp" 457 | version = "3.5.0" 458 | description = "Backport of pathlib-compatible object wrapper for zip files" 459 | category = "main" 460 | optional = false 461 | python-versions = ">=3.6" 462 | 463 | [package.extras] 464 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 465 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 466 | 467 | [metadata] 468 | lock-version = "1.1" 469 | python-versions = "^3.7" 470 | content-hash = "40ab4278473a6f22e1084753bbfdc0f0ae7c644bd37ec8cebd70f890b6d15792" 471 | 472 | [metadata.files] 473 | appdirs = [ 474 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 475 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 476 | ] 477 | atomicwrites = [ 478 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 479 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 480 | ] 481 | attrs = [ 482 | {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, 483 | {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, 484 | ] 485 | aw-client = [ 486 | {file = "aw-client-0.5.4.tar.gz", hash = "sha256:d14642226773a59e90cc353f69146759765560d027adc40eb5e169989f0d7556"}, 487 | {file = "aw_client-0.5.4-py3-none-any.whl", hash = "sha256:5a3a12ab6771cbc95c08fa631e069f058781aa8b66e5c1985ca51a87f8f38efe"}, 488 | ] 489 | aw-core = [ 490 | {file = "aw-core-0.5.4.tar.gz", hash = "sha256:0450eb4958330021d6e65a11da41e41e534d504fcde188114eb06fca6fb4ea20"}, 491 | {file = "aw_core-0.5.4-py3-none-any.whl", hash = "sha256:0b3324b4f8913506c467e6519ceccacf05a39bc095217ce86a0a4e40460c1acc"}, 492 | ] 493 | certifi = [ 494 | {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, 495 | {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, 496 | ] 497 | charset-normalizer = [ 498 | {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"}, 499 | {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"}, 500 | ] 501 | click = [ 502 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, 503 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, 504 | ] 505 | colorama = [ 506 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 507 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 508 | ] 509 | croniter = [ 510 | {file = "croniter-1.0.15-py2.py3-none-any.whl", hash = "sha256:0f97b361fe343301a8f66f852e7d84e4fb7f21379948f71e1bbfe10f5d015fbd"}, 511 | {file = "croniter-1.0.15.tar.gz", hash = "sha256:a70dfc9d52de9fc1a886128b9148c89dd9e76b67d55f46516ca94d2d73d58219"}, 512 | ] 513 | deprecation = [ 514 | {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"}, 515 | {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"}, 516 | ] 517 | idna = [ 518 | {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, 519 | {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, 520 | ] 521 | importlib-metadata = [ 522 | {file = "importlib_metadata-4.6.3-py3-none-any.whl", hash = "sha256:51c6635429c77cf1ae634c997ff9e53ca3438b495f10a55ba28594dd69764a8b"}, 523 | {file = "importlib_metadata-4.6.3.tar.gz", hash = "sha256:0645585859e9a6689c523927a5032f2ba5919f1f7d0e84bd4533312320de1ff9"}, 524 | ] 525 | iso8601 = [ 526 | {file = "iso8601-0.1.16-py2.py3-none-any.whl", hash = "sha256:906714829fedbc89955d52806c903f2332e3948ed94e31e85037f9e0226b8376"}, 527 | {file = "iso8601-0.1.16.tar.gz", hash = "sha256:36532f77cc800594e8f16641edae7f1baf7932f05d8e508545b95fc53c6dc85b"}, 528 | ] 529 | jsonschema = [ 530 | {file = "jsonschema-3.2.0-py2.py3-none-any.whl", hash = "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163"}, 531 | {file = "jsonschema-3.2.0.tar.gz", hash = "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a"}, 532 | ] 533 | loguru = [ 534 | {file = "loguru-0.5.3-py3-none-any.whl", hash = "sha256:f8087ac396b5ee5f67c963b495d615ebbceac2796379599820e324419d53667c"}, 535 | {file = "loguru-0.5.3.tar.gz", hash = "sha256:b28e72ac7a98be3d28ad28570299a393dfcd32e5e3f6a353dec94675767b6319"}, 536 | ] 537 | more-itertools = [ 538 | {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, 539 | {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, 540 | ] 541 | packaging = [ 542 | {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, 543 | {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, 544 | ] 545 | peewee = [ 546 | {file = "peewee-3.14.4.tar.gz", hash = "sha256:9e356b327c2eaec6dd42ecea6f4ddded025793dba906a3d065a0452e726c51a2"}, 547 | ] 548 | persist-queue = [ 549 | {file = "persist-queue-0.6.0.tar.gz", hash = "sha256:e73dd62545d37e519247d96368bfa5c510fde66999a338d6d2d44790dc10f89b"}, 550 | {file = "persist_queue-0.6.0-py2.py3-none-any.whl", hash = "sha256:b7a6a6e642bed23076f03d15d08d87aebad32029f3e702cc10f5b86d6fbd0cb7"}, 551 | ] 552 | pluggy = [ 553 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 554 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 555 | ] 556 | py = [ 557 | {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, 558 | {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, 559 | ] 560 | pyparsing = [ 561 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 562 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 563 | ] 564 | pyrsistent = [ 565 | {file = "pyrsistent-0.18.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f4c8cabb46ff8e5d61f56a037974228e978f26bfefce4f61a4b1ac0ba7a2ab72"}, 566 | {file = "pyrsistent-0.18.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:da6e5e818d18459fa46fac0a4a4e543507fe1110e808101277c5a2b5bab0cd2d"}, 567 | {file = "pyrsistent-0.18.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5e4395bbf841693eaebaa5bb5c8f5cdbb1d139e07c975c682ec4e4f8126e03d2"}, 568 | {file = "pyrsistent-0.18.0-cp36-cp36m-win32.whl", hash = "sha256:527be2bfa8dc80f6f8ddd65242ba476a6c4fb4e3aedbf281dfbac1b1ed4165b1"}, 569 | {file = "pyrsistent-0.18.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2aaf19dc8ce517a8653746d98e962ef480ff34b6bc563fc067be6401ffb457c7"}, 570 | {file = "pyrsistent-0.18.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58a70d93fb79dc585b21f9d72487b929a6fe58da0754fa4cb9f279bb92369396"}, 571 | {file = "pyrsistent-0.18.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4916c10896721e472ee12c95cdc2891ce5890898d2f9907b1b4ae0f53588b710"}, 572 | {file = "pyrsistent-0.18.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:73ff61b1411e3fb0ba144b8f08d6749749775fe89688093e1efef9839d2dcc35"}, 573 | {file = "pyrsistent-0.18.0-cp37-cp37m-win32.whl", hash = "sha256:b29b869cf58412ca5738d23691e96d8aff535e17390128a1a52717c9a109da4f"}, 574 | {file = "pyrsistent-0.18.0-cp37-cp37m-win_amd64.whl", hash = "sha256:097b96f129dd36a8c9e33594e7ebb151b1515eb52cceb08474c10a5479e799f2"}, 575 | {file = "pyrsistent-0.18.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:772e94c2c6864f2cd2ffbe58bb3bdefbe2a32afa0acb1a77e472aac831f83427"}, 576 | {file = "pyrsistent-0.18.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c1a9ff320fa699337e05edcaae79ef8c2880b52720bc031b219e5b5008ebbdef"}, 577 | {file = "pyrsistent-0.18.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cd3caef37a415fd0dae6148a1b6957a8c5f275a62cca02e18474608cb263640c"}, 578 | {file = "pyrsistent-0.18.0-cp38-cp38-win32.whl", hash = "sha256:e79d94ca58fcafef6395f6352383fa1a76922268fa02caa2272fff501c2fdc78"}, 579 | {file = "pyrsistent-0.18.0-cp38-cp38-win_amd64.whl", hash = "sha256:a0c772d791c38bbc77be659af29bb14c38ced151433592e326361610250c605b"}, 580 | {file = "pyrsistent-0.18.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d5ec194c9c573aafaceebf05fc400656722793dac57f254cd4741f3c27ae57b4"}, 581 | {file = "pyrsistent-0.18.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:6b5eed00e597b5b5773b4ca30bd48a5774ef1e96f2a45d105db5b4ebb4bca680"}, 582 | {file = "pyrsistent-0.18.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:48578680353f41dca1ca3dc48629fb77dfc745128b56fc01096b2530c13fd426"}, 583 | {file = "pyrsistent-0.18.0-cp39-cp39-win32.whl", hash = "sha256:f3ef98d7b76da5eb19c37fda834d50262ff9167c65658d1d8f974d2e4d90676b"}, 584 | {file = "pyrsistent-0.18.0-cp39-cp39-win_amd64.whl", hash = "sha256:404e1f1d254d314d55adb8d87f4f465c8693d6f902f67eb6ef5b4526dc58e6ea"}, 585 | {file = "pyrsistent-0.18.0.tar.gz", hash = "sha256:773c781216f8c2900b42a7b638d5b517bb134ae1acbebe4d1e8f1f41ea60eb4b"}, 586 | ] 587 | pytest = [ 588 | {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, 589 | {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, 590 | ] 591 | python-dateutil = [ 592 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 593 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 594 | ] 595 | python-json-logger = [ 596 | {file = "python-json-logger-0.1.11.tar.gz", hash = "sha256:b7a31162f2a01965a5efb94453ce69230ed208468b0bbc7fdfc56e6d8df2e281"}, 597 | ] 598 | pyzenity = [] 599 | requests = [ 600 | {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, 601 | {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, 602 | ] 603 | six = [ 604 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 605 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 606 | ] 607 | strict-rfc3339 = [ 608 | {file = "strict-rfc3339-0.7.tar.gz", hash = "sha256:5cad17bedfc3af57b399db0fed32771f18fc54bbd917e85546088607ac5e1277"}, 609 | ] 610 | takethetime = [ 611 | {file = "TakeTheTime-0.3.1.tar.gz", hash = "sha256:dbe30453a1b596a38f9e2e3fa8e1adc5af2dbf646ca0837ad5c2059a16fe2ff9"}, 612 | ] 613 | timeout-decorator = [ 614 | {file = "timeout-decorator-0.5.0.tar.gz", hash = "sha256:6a2f2f58db1c5b24a2cc79de6345760377ad8bdc13813f5265f6c3e63d16b3d7"}, 615 | ] 616 | timeslot = [ 617 | {file = "timeslot-0.1.2-py3-none-any.whl", hash = "sha256:2f8efaec7b0a4c1e56a92ec05533219332dd9d8b577539077664c233996911b5"}, 618 | {file = "timeslot-0.1.2.tar.gz", hash = "sha256:a2ac998657e3f3b9ca928757b4906add2c05390c5fc14ed792bb9028d08547b1"}, 619 | ] 620 | tomlkit = [ 621 | {file = "tomlkit-0.7.2-py2.py3-none-any.whl", hash = "sha256:173ad840fa5d2aac140528ca1933c29791b79a374a0861a80347f42ec9328117"}, 622 | {file = "tomlkit-0.7.2.tar.gz", hash = "sha256:d7a454f319a7e9bd2e249f239168729327e4dd2d27b17dc68be264ad1ce36754"}, 623 | ] 624 | typer = [ 625 | {file = "typer-0.3.2-py3-none-any.whl", hash = "sha256:ba58b920ce851b12a2d790143009fa00ac1d05b3ff3257061ff69dbdfc3d161b"}, 626 | {file = "typer-0.3.2.tar.gz", hash = "sha256:5455d750122cff96745b0dec87368f56d023725a7ebc9d2e54dd23dc86816303"}, 627 | ] 628 | typing-extensions = [ 629 | {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, 630 | {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, 631 | {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, 632 | ] 633 | unidecode = [ 634 | {file = "Unidecode-1.2.0-py2.py3-none-any.whl", hash = "sha256:12435ef2fc4cdfd9cf1035a1db7e98b6b047fe591892e81f34e94959591fad00"}, 635 | {file = "Unidecode-1.2.0.tar.gz", hash = "sha256:8d73a97d387a956922344f6b74243c2c6771594659778744b2dbdaad8f6b727d"}, 636 | ] 637 | urllib3 = [ 638 | {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"}, 639 | {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, 640 | ] 641 | wcwidth = [ 642 | {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, 643 | {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, 644 | ] 645 | win32-setctime = [ 646 | {file = "win32_setctime-1.0.3-py3-none-any.whl", hash = "sha256:dc925662de0a6eb987f0b01f599c01a8236cb8c62831c22d9cada09ad958243e"}, 647 | {file = "win32_setctime-1.0.3.tar.gz", hash = "sha256:4e88556c32fdf47f64165a2180ba4552f8bb32c1103a2fafd05723a0bd42bd4b"}, 648 | ] 649 | zipp = [ 650 | {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"}, 651 | {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"}, 652 | ] 653 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "aw-watcher-ask" 3 | version = "0.1.0" 4 | description = "An ActivityWatch watcher to randomly pose questions to the user." 5 | authors = ["bcbernardo "] 6 | 7 | [tool.poetry.scripts] 8 | aw-watcher-ask = "aw_watcher_ask.cli:app" 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.7" 12 | typer = "^0.4" 13 | aw-client = "^0.5.4" 14 | croniter = "^1.0.15" 15 | loguru = "^0.5.3" 16 | Unidecode = "^1.2.0" 17 | timeout-decorator = "^0.5.0" 18 | pyzenity = {git = "https://github.com/bcbernardo/Zenity.git", rev="ab46b78"} 19 | 20 | [tool.poetry.dev-dependencies] 21 | pytest = "^5.2" 22 | 23 | [build-system] 24 | requires = ["poetry-core>=1.0.0"] 25 | build-backend = "poetry.core.masonry.api" 26 | -------------------------------------------------------------------------------- /src/aw_watcher_ask/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Bernardo Chrispim Baron 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | 6 | __version__ = "0.1.0" 7 | -------------------------------------------------------------------------------- /src/aw_watcher_ask/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # SPDX-FileCopyrightText: Bernardo Chrispim Baron 4 | # 5 | # SPDX-License-Identifier: MIT 6 | 7 | 8 | """Main entrypoint to aw-watcher ask.""" 9 | 10 | 11 | from aw_watcher_ask.cli import app 12 | 13 | 14 | if __name__ == "__main__": 15 | app(prog_name="aw-input-watcher") 16 | -------------------------------------------------------------------------------- /src/aw_watcher_ask/cli.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Bernardo Chrispim Baron 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | 6 | """A command-line interface (CLI) to aw-watcher-ask.""" 7 | 8 | 9 | from datetime import datetime 10 | from typing import Dict, List, Optional, Union 11 | 12 | import typer 13 | 14 | from aw_watcher_ask import __version__ 15 | from aw_watcher_ask.core import main 16 | from aw_watcher_ask.models import DialogType 17 | 18 | 19 | app = typer.Typer() 20 | 21 | 22 | def _parse_extra_args( 23 | extra_args: List[str] 24 | ) -> Dict[str, Union[bool, str, List[str]]]: 25 | """Processes any number of unknown CLI arguments and/or options. 26 | 27 | Arguments: 28 | extra_args: A list of unprocessed arguments and/or options forwarded 29 | by a Click/Typer command-line application. 30 | 31 | Returns: 32 | A dictionary of option names and values. 33 | """ 34 | 35 | options: Dict[str, Union[bool, str, List[str]]] = dict() 36 | 37 | # iterate over unparsed options 38 | for ix in range(0, len(extra_args)): 39 | 40 | # check whether the element in this position starts with an option name 41 | if extra_args[ix].startswith("-"): 42 | 43 | # if it is, remove it from un parsed args and split the option name 44 | # and an optional value (if format `--name=value` was used) 45 | option_name, *option_values = extra_args[ix].split("=", 1) 46 | option_name = option_name.lstrip("-") 47 | 48 | if not option_values: 49 | # no value in `=`-separated value: keep parsing for 50 | # (possibly multiple) values, provided in the format 51 | # `--name option1 option 2` 52 | while True: 53 | if extra_args[ix].startswith("-"): 54 | # found the next option name; stop looking for values 55 | break 56 | else: 57 | # is a value; remove it from unparsed args and store it 58 | option_values.append(extra_args[ix]) 59 | 60 | # have any value been found? 61 | if len(option_values) == 0: 62 | # no: assume option was a flag, and store True 63 | options[option_name] = True 64 | elif len(option_values) == 1: 65 | # yes, one value has been found: unpack it and store it 66 | options[option_name] = option_values[0] 67 | else: 68 | # multiple values have been found: store them as a list 69 | options[option_name] = option_values 70 | 71 | return options 72 | 73 | 74 | @app.callback(invoke_without_command=True) 75 | def callback( 76 | ctx: typer.Context, 77 | version: Optional[bool] = typer.Option( 78 | False, "--version", help="Show program version.", show_default=False 79 | ), 80 | ): 81 | """Gathers user's inputs and send them to ActivityWatch. 82 | 83 | This watcher periodically presents a dialog box to the user, and stores the 84 | provided answer on the locally running ActivityWatch server. It relies on 85 | Zenity to construct simple graphic interfaces. 86 | """ 87 | if version and ctx.invoked_subcommand is None: 88 | typer.echo(__version__) 89 | typer.Exit() 90 | 91 | 92 | @app.command(context_settings={ 93 | "allow_extra_args": True, 94 | "ignore_unknown_options": True, 95 | "allow_interspersed_args": False, 96 | }) 97 | def run( 98 | ctx: typer.Context, 99 | question_type: DialogType = typer.Option(..., help=( 100 | "The type of dialog box to present the user." 101 | )), 102 | question_id: str = typer.Option(..., help=( 103 | "A short string to identify your question in ActivityWatch " 104 | "server records. Should contain only lower-case letters, numbers and " 105 | "dots. If `--title` is not provided, this will also be the " 106 | "key to identify the content of the answer in the ActivityWatch " 107 | "bucket's raw data." 108 | )), 109 | title: Optional[str] = typer.Option(None, help=( 110 | "An optional title for the question. If provided, this will be both " 111 | "the title of the dialog box and the key that identifies the content " 112 | "of the answer in the ActivityWatch bucket's raw data." 113 | )), 114 | schedule: str = typer.Option("R * * * *", help=( 115 | "A cron-tab expression (see https://en.wikipedia.org/wiki/Cron) " 116 | "that controls the execution intervals at which the user should be " 117 | "prompted to answer the given question. Accepts 'R' as a keyword at " 118 | "second, minute and hour positions, for prompting at random times." 119 | "Might be a classic five-element expression, or optionally have a " 120 | "sixth element to indicate the seconds." 121 | )), 122 | until: datetime = typer.Option("2100-12-31", help=( 123 | "A date and time when to stop gathering input from the user." 124 | )), 125 | timeout: int = typer.Option( 126 | 60, help="The amount of seconds to wait for user's input." 127 | ), 128 | testing: bool = typer.Option( 129 | False, help="If set, starts ActivityWatch Client in testing mode." 130 | ), 131 | ): 132 | params = locals().copy() 133 | params.pop("ctx", None) 134 | params = dict(params, **_parse_extra_args(ctx.args)) 135 | main(**params) 136 | -------------------------------------------------------------------------------- /src/aw_watcher_ask/core.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Bernardo Chrispim Baron 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | 6 | """Watcher function and helpers.""" 7 | 8 | 9 | import sys 10 | import time 11 | from datetime import datetime 12 | from typing import Any, Dict, Optional 13 | 14 | import zenity 15 | from aw_client import ActivityWatchClient 16 | from aw_core.models import Event 17 | from croniter import croniter 18 | from loguru import logger 19 | 20 | from aw_watcher_ask.models import DialogType 21 | from aw_watcher_ask.utils import fix_id, is_valid_id, get_current_datetime 22 | 23 | 24 | def _bucket_setup(client: ActivityWatchClient, question_id: str) -> str: 25 | """Makes sure a bucket exists in the client for the given event type.""" 26 | 27 | bucket_id = "{}_{}".format(client.client_name, client.client_hostname) 28 | client.create_bucket(bucket_id, event_type=question_id) 29 | 30 | return bucket_id 31 | 32 | 33 | def _client_setup(testing: bool = False) -> ActivityWatchClient: 34 | """Builds a new ActivityWatcher client instance and bucket.""" 35 | 36 | # set client name 37 | client_name = "aw-watcher-ask" 38 | if testing: 39 | client_name = "test-" + client_name 40 | 41 | # create client representation 42 | return ActivityWatchClient(client_name, testing=testing) 43 | 44 | 45 | def _ask_one( 46 | question_type: DialogType, title: str, *args, **kwargs 47 | ) -> Dict[str, Any]: 48 | """Captures an user's response to a dialog box with a single field.""" 49 | kwargs.pop("ctx", None) 50 | success, content = zenity.show( 51 | question_type.value, title=title, *args, **kwargs 52 | ) 53 | return { 54 | "success": success, 55 | title: content, 56 | } 57 | 58 | 59 | def _ask_many( 60 | question_type: DialogType, separator: str = "|", *args, **kwargs 61 | ) -> Dict[str, Any]: 62 | """Captures the user's response to a dialog box with multiple fields.""" 63 | raise NotImplementedError 64 | 65 | 66 | def main( 67 | question_id: str, 68 | question_type: DialogType = DialogType.question, 69 | title: Optional[str] = None, 70 | schedule: str = "R * * * *", 71 | until: datetime = datetime(2100, 12, 31), 72 | timeout: int = 60, 73 | testing: bool = False, 74 | *args, 75 | **kwargs, 76 | ) -> None: 77 | """Gathers user's inputs and send them to ActivityWatch. 78 | 79 | This watcher periodically presents a dialog box to the user, and stores the 80 | provided answer on the locally running [ActivityWatch] 81 | (https://docs.activitywatch.net/) server. It relies on [Zenity] 82 | (https://help.gnome.org/users/zenity/stable/index.html.en) to construct 83 | simple graphic interfaces. 84 | 85 | Arguments: 86 | question_id: A short string to identify your question in ActivityWatch 87 | server records. Should contain only lower-case letters, numbers and 88 | dots. If `title` is not provided, this will also be the 89 | key to identify the content of the answer in the ActivityWatch 90 | bucket's raw data. 91 | question_type: The type of dialog box to present the user, provided as 92 | one of [`aw_watcher_ask.models.DialogType`] 93 | [aw_watcher_ask.models.DialogType] enumeration types. Currently, 94 | `DialogType.forms`, `DialogType.list` and 95 | `DialogType.file_selection` are not supported. Defaults to 96 | `DialogType.question`. 97 | title: An optional title for the question. If provided, this 98 | will be both the title of the dialog box and the key that 99 | identifies the content of the answer in the ActivityWatch bucket's 100 | raw data. 101 | schedule: A [cron-tab expression](https://en.wikipedia.org/wiki/Cron) 102 | that controls the execution intervals at which the user should be 103 | prompted to answer the given question. Accepts 'R' as a keyword at 104 | second, minute and hour positions, for prompting at random times. 105 | Might be a classic five-element expression, or optionally have a 106 | sixth element to indicate the seconds. 107 | until: A [`datetime.datetime`] 108 | (https://docs.python.org/3/library/datetime.html#datetime-objects) 109 | object, that indicates the date and time when to stop gathering 110 | input from the user. Defaults to `datetime(2100, 12, 31)`. 111 | timeout: The amount of seconds to wait for user's input. Defaults to 112 | 60 seconds. 113 | testing: Whether to run the [`aw_client.ActivityWatchClient`] 114 | (https://docs.activitywatch.net/en/latest/api/python.html 115 | #aw_client.ActivityWatchClient) client in testing mode. 116 | *args: Variable lenght argument list to be passed to [`zenity.show()`] 117 | (https://pyzenity.gitbook.io/docs/) Zenity wrapper. 118 | **kwargs: Variable lenght argument list to be passed to 119 | [`zenity.show()`](https://pyzenity.gitbook.io/docs/) Zenity 120 | wrapper. 121 | 122 | Raises: 123 | NotImplementedError: If the provided `question_type` is one of 124 | `DialogType.forms`, `DialogType.list` or 125 | `DialogType.file_selection`. 126 | """ 127 | 128 | log_format = "{time} <{extra[question_id]}>: {level} - {message}" 129 | logger.add(sys.stderr, level="INFO", format=log_format) 130 | log = logger.bind(question_id=question_id) 131 | 132 | log.info("Starting new watcher...") 133 | 134 | # fix question-id if it was provided with forbidden characters 135 | if not is_valid_id(question_id): 136 | question_id = fix_id(question_id) 137 | log.warning( 138 | f"An invalid question_id was provided. Fixed to `{question_id}`." 139 | ) 140 | log = log.bind(question_id=question_id) 141 | 142 | # fix offset-naive datetimes 143 | if not until.tzinfo: 144 | system_timezone = get_current_datetime().astimezone().tzinfo 145 | until = until.replace(tzinfo=system_timezone) 146 | 147 | # start client and bucket 148 | client = _client_setup(testing=testing) 149 | log.info( 150 | f"Client created and connected to server at {client.server_address}." 151 | ) 152 | bucket_id = _bucket_setup(client, question_id) 153 | 154 | # execution schedule 155 | executions = croniter(schedule, start_time=get_current_datetime()) 156 | 157 | # run service 158 | while get_current_datetime() < until: 159 | # wait until next execution 160 | next_execution = executions.get_next(datetime) 161 | log.info( 162 | f"Next execution scheduled to {next_execution.isoformat()}." 163 | ) 164 | sleep_time = next_execution - get_current_datetime() 165 | time.sleep(max(sleep_time.seconds, 0)) 166 | 167 | log.info( 168 | "New prompt fired. Waiting for user input..." 169 | ) 170 | if question_type.value in ["forms", "file-selection", "list"]: 171 | # TODO: not implemented 172 | answer = _ask_many( 173 | question_type=question_type, 174 | title=title, 175 | timeout=timeout, 176 | *args, 177 | **kwargs, 178 | ) 179 | else: 180 | answer = _ask_one( 181 | question_type=question_type, 182 | title=( 183 | title if title else question_id 184 | ), 185 | timeout=timeout, 186 | *args, 187 | **kwargs, 188 | ) 189 | if not answer["success"]: 190 | log.info("Prompt timed out with no response from user.") 191 | 192 | event = Event(timestamp=get_current_datetime(), data=answer) 193 | client.insert_event(bucket_id, event) 194 | log.info(f"Event stored in bucket '{bucket_id}'.") 195 | -------------------------------------------------------------------------------- /src/aw_watcher_ask/models.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Bernardo Chrispim Baron 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """Representations for exchanging data with Zenity and ActivityWatch.""" 6 | 7 | 8 | from enum import Enum 9 | 10 | 11 | class DialogType(str, Enum): 12 | calendar = "calendar" # Display calendar dialog 13 | entry = "entry" # Display text entry dialog 14 | error = "error" # Display error dialog 15 | info = "info" # Display info dialog 16 | file_selection = "file-selection" # Display file selection dialog 17 | list = "list" # Display list dialog 18 | notification = "notification" # Display notification 19 | progress = "progress" # Display progress indication dialog 20 | warning = "warning" # Display warning dialog 21 | scale = "scale" # Display scale dialog 22 | text_info = "text-info" # Display text information dialog 23 | color_selection = "color-selection" # Display color selection dialog 24 | question = "question" # Display question dialog 25 | password = "password" # Display password dialog 26 | forms = "forms" # Display forms dialog 27 | -------------------------------------------------------------------------------- /src/aw_watcher_ask/utils.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Bernardo Chrispim Baron 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | """General utilities for interacting with Zenity and ActivityWatch.""" 6 | 7 | 8 | import re 9 | from datetime import datetime, timezone 10 | 11 | from unidecode import unidecode 12 | 13 | 14 | def fix_id(question_id: str) -> str: 15 | """Replaces forbidden characters in a question_id.""" 16 | return re.sub(r"[^a-z0-9]", ".", unidecode(question_id).lower()) 17 | 18 | 19 | def is_valid_id(question_id: str) -> bool: 20 | """Checks whether a given question_id contains only accepted characters.""" 21 | return not bool(re.search(r"[^a-z0-9.]", question_id)) 22 | 23 | 24 | def get_current_datetime() -> datetime: 25 | """Returns the current UTC date and time.""" 26 | return datetime.now(timezone.utc) 27 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Bernardo Chrispim Baron 2 | # 3 | # SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Bernardo Chrispim Baron 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | 6 | """Tests running aw-watcher-input from the command-line interface.""" 7 | 8 | 9 | import re 10 | from datetime import datetime, timedelta 11 | 12 | import pytest 13 | from typer.testing import CliRunner 14 | 15 | from aw_watcher_ask.cli import app 16 | 17 | 18 | @pytest.fixture(scope="function") 19 | def runner(): 20 | """Provides a command-line test runner.""" 21 | return CliRunner() 22 | 23 | 24 | def test_version(runner): 25 | """Tests getting app version.""" 26 | result = runner.invoke(app, ["--version"]) 27 | assert result.exit_code == 0 28 | assert re.match(r"[0-9]+\.[0-9]+\.[0-9]+", result.stdout) 29 | 30 | 31 | @pytest.mark.parametrize("question_id", ["accepted.id", "Forbiddên_ID"]) 32 | @pytest.mark.parametrize("question_type", ["question", "entry"]) 33 | @pytest.mark.parametrize("schedule", ["* * * * * */4"]) 34 | def test_app(runner, question_id, question_type, schedule): 35 | end_time = datetime.now() + timedelta(seconds=9) 36 | result = runner.invoke( 37 | app, 38 | [ 39 | "run", 40 | question_type, 41 | "--testing", 42 | "--id", 43 | question_id, 44 | "--schedule", 45 | schedule, 46 | "--until", 47 | end_time.isoformat(timespec="seconds"), 48 | "--timeout", 49 | 2, 50 | ], 51 | ) 52 | assert result.exit_code == 0 53 | assert "INFO - Starting new watcher" in result.output 54 | assert "INFO - Client created" in result.output 55 | assert "INFO - Next execution scheduled" in result.output 56 | assert "INFO - New prompt fired" in result.output 57 | assert "INFO - Prompt timed out" in result.output 58 | assert "INFO - Event stored in bucket" in result.output 59 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Bernardo Chrispim Baron 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | 6 | """Tests for aw-watcher-ask main logic.""" 7 | 8 | 9 | from datetime import datetime, timedelta, timezone 10 | from random import randint 11 | from typing import Optional 12 | 13 | import pytest 14 | from aw_client import ActivityWatchClient 15 | 16 | from aw_watcher_ask.core import ( 17 | _ask_many, _ask_one, _client_setup, _bucket_setup, main 18 | ) 19 | from aw_watcher_ask.models import DialogType 20 | 21 | 22 | def test_client_setup(): 23 | """Tests instantiating an ActivityWatch client object.""" 24 | client = _client_setup(testing=True) 25 | assert client.client_name == "test-aw-watcher-ask" 26 | assert client.client_hostname == "localhost.localdomain" 27 | info = client.get_info() 28 | assert "hostname" in info 29 | assert "testing" in info 30 | with client: 31 | assert True 32 | client.connect() 33 | client.disconnect() 34 | 35 | 36 | def test_bucket_setup(): 37 | """Tests creating and deleting a bucket""" 38 | with ActivityWatchClient("test-client", testing=True) as client: 39 | 40 | # create bucket 41 | new_bucket_id = _bucket_setup(client, question_id="test.question") 42 | buckets = client.get_buckets() 43 | assert any(bucket == new_bucket_id for bucket in buckets) 44 | 45 | # delete bucket 46 | client.delete_bucket(new_bucket_id) 47 | buckets = client.get_buckets() 48 | assert not any(bucket == new_bucket_id for bucket in buckets) 49 | 50 | 51 | def test_ask_question(): 52 | """Tests asking a question with a single answer field to the user.""" 53 | answer = _ask_one(DialogType("question"), "Test question", timeout=2) 54 | assert "success" in answer 55 | assert not answer["success"] 56 | assert "Test question" in answer 57 | assert len(answer["Test question"]) == 0 58 | 59 | 60 | def test_ask_many(): 61 | """Tests asking a question with multiple answer fields to the user.""" 62 | with pytest.raises(NotImplementedError): 63 | _ask_many(DialogType("forms"), timeout=5) 64 | 65 | 66 | @pytest.mark.parametrize("question_type", ["question"]) 67 | @pytest.mark.parametrize("title", ["Test question", None]) 68 | def test_main_one(question_type: str, title: Optional[str]): 69 | """Tests periodically asking a single question and storing user's input.""" 70 | with ActivityWatchClient("test-client", testing=True) as client: 71 | question_id = "test.question" + str(randint(0, 10 ** 10)) 72 | bucket_id = "test-aw-watcher-ask_localhost.localdomain" 73 | try: 74 | start_time = datetime.now(timezone.utc) 75 | end_time = start_time + timedelta(seconds=9) 76 | main( 77 | question_type=DialogType("question"), 78 | question_id=question_id, 79 | title=title, 80 | schedule="* * * * * */4", 81 | until=end_time, 82 | timeout=2, 83 | testing=True, 84 | ) 85 | last_event = client.get_events(bucket_id=bucket_id, limit=1)[0] 86 | assert last_event.timestamp > start_time 87 | assert last_event.timestamp < end_time + timedelta(seconds=2) 88 | assert "success" in last_event.data 89 | assert not last_event.data["success"] 90 | if not title: 91 | assert question_id in last_event.data 92 | assert len(last_event.data[question_id]) == 0 93 | else: 94 | assert title in last_event.data 95 | assert len(last_event.data[title]) == 0 96 | finally: 97 | client.delete_bucket(bucket_id) 98 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Bernardo Chrispim Baron 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | 6 | """Tests for the utilities to interact with Zenity and ActivityWatch.""" 7 | 8 | import pytest 9 | from datetime import datetime 10 | 11 | from aw_watcher_ask.utils import fix_id, is_valid_id, get_current_datetime 12 | 13 | 14 | @pytest.mark.parametrize("valid_id", ["a.correct.id"]) 15 | def testis_valid_id(valid_id: str): 16 | """Tests recognizing a valid event_type id.""" 17 | assert is_valid_id(valid_id) 18 | 19 | 20 | @pytest.mark.parametrize( 21 | "invalid_id", 22 | [ 23 | "a string with spaces", 24 | "a_string_with_underscores", 25 | "ã.string.wïth.nonáscii.çhars", 26 | "AN.UPPERCASE.STRING", 27 | ], 28 | ) 29 | def test_isnot_valid_id(invalid_id: str): 30 | """Tests recognizing forbidden event_type ids.""" 31 | assert not is_valid_id(invalid_id) 32 | 33 | 34 | @pytest.mark.parametrize("valid_id", ["a.correct.id"]) 35 | def testfix_valid_id(valid_id: str): 36 | """Tests applying fix to an already correct event_type id.""" 37 | transformed_id = fix_id(valid_id) 38 | assert is_valid_id(transformed_id) 39 | assert valid_id == transformed_id 40 | 41 | 42 | @pytest.mark.parametrize( 43 | "invalid_id", 44 | [ 45 | "a string with spaces", 46 | "a_string_with_underscores", 47 | "ã.string.wïth.nonáscii.çhars", 48 | "AN.UPPERCASE.STRING", 49 | ], 50 | ) 51 | def testfix_invalid_id(invalid_id: str): 52 | """Tests applying fix to event_type ids with incorrect .""" 53 | assert is_valid_id(fix_id(invalid_id)) 54 | 55 | 56 | def test_get_current_datetime(): 57 | """Returns the current UTC date and time.""" 58 | now = get_current_datetime() 59 | assert isinstance(now, datetime) 60 | assert now.tzname() == "UTC" 61 | --------------------------------------------------------------------------------