diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b414c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,148 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# End of https://www.toptal.com/developers/gitignore/api/python + +# VSCode +.vscode \ No newline at end of file diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..32e53e7 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f82be1 --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ + + +# aw-watcher-ask + +An [ActivityWatch][] watcher to pose questions to the user and record her +answers. + +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. + +[ActivityWatch]: https://activitywatch.readthedocs.io/en/latest/ + +## Table of Contents + +- [aw-watcher-ask](#aw-watcher-ask) + - [Table of Contents](#table-of-contents) + - [Install](#install) + - [Using `pip`/`pipx`](#using-pippipx) + - [From source](#from-source) + - [Usage](#usage) + - [CLI](#cli) + - [Accessing the data](#accessing-the-data) + - [Security](#security) + - [Limitations and Roadmap](#limitations-and-roadmap) + - [Maintainers](#maintainers) + - [Contributing](#contributing) + - [License](#license) + +## Install + +### Using `pip`/`pipx` + +Create a [virtual environment][venv], activate it and run: + +```sh +$ python3 -m pip install git+https://github.com/bcbernardo/aw-watcher-ask.git +Collecting git+https://github.com/bcbernardo/aw-watcher-ask.git +... ... +Installing collected packages: aw-watcher-ask +Successfully installed aw-watcher-ask-0.1.0 +``` + +Alternatively, you may use [`pipx`][pipx] to abstract away the creation of the virtual environment, and make sure the package is globally available: + +```sh +$ pipx install git+https://github.com/bcbernardo/aw-watcher-ask.git + installed package aw-watcher-ask 0.1.0, Python 3.9.6 + These apps are now globally available + - aw-watcher-ask +done! ✨ 🌟 ✨ +``` + +[venv]: https://docs.python.org/3/tutorial/venv.html +[pipx]: https://pypa.github.io/pipx/ + +### From source + +To install the watcher, clone the repository to your local filesystem and +install it with [poetry](https://python-poetry.org/docs): + +```sh +$ git clone https://github.com/bcbernardo/aw-watcher-ask.git +$ cd aw-watcher-ask +$ poetry install +... ... +Installing the current project: aw-watcher-ask (0.1.0) +$ poetry shell # alternatively, add `poetry run` before every command in the examples below +``` + +## Usage + +Before you start using `aw-watcher-input`, make sure you have ActivityWatch [installed and running][AW installation]. + +[AW installation]: https://docs.activitywatch.net/en/latest/getting-started.html + +### CLI + +The following command will show the dialog box below each hour at 00 minutes +and 00 seconds, wait up to 120 seconds for the user's response, and save it to +a bucket in the local ActivityWatcher instance. + +```sh +$ 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" +... ... +``` + +![Example dialog asking if the user is happy](./assets/img/example_dialog.png) + +Check `aw-watcher-ask run --help` to see all required and optional control parameters. + +The `--question-id` is used to identify this particular question in the ActivityWatcher a `aw-watcher-input` bucket, and is therefore mandatory. + +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. + +[Zenity Manual]: https://help.gnome.org/users/zenity/stable/ + +### Accessing the data + +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. + +[AW API]: https://docs.activitywatch.net/en/latest/api/rest.html + +## Security + +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. + +[AW watchers]: https://docs.activitywatch.net/en/latest/watchers.html + +## Limitations and Roadmap + +`aw-watcher-ask` is in a very early development stage. Expect bugs and strange behaviors when using it. + +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.). + +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. It have not been tested with `aw-watcher-ask` though, and may therefore behave unexpectedly. + +`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. + +## Maintainers + +- Bernardo Chrispim Baron ([@bcbernardo](https://github.com/bcbernardo)) + +## Contributing + +PRs accepted. Please [open an issue][new issue] if you have an idea for enhancement or have spotted a bug. + +[new issue]: https://github.com/bcbernardo/aw-watcher-ask/issues/new/choose + +## License + +MIT License + +Copyright (c) 2021 Bernardo Chrispim Baron + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/img/example_dialog.png b/assets/img/example_dialog.png new file mode 100644 index 0000000..e7ca501 Binary files /dev/null and b/assets/img/example_dialog.png differ diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..ac21139 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,652 @@ +[[package]] +name = "appdirs" +version = "1.4.4" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.2.0" +description = "Classes Without Boilerplate" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +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"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] + +[[package]] +name = "aw-client" +version = "0.5.4" +description = "Client library for ActivityWatch" +category = "main" +optional = false +python-versions = ">=3.7,<4.0" + +[package.dependencies] +aw-core = ">=0.5.1,<0.6.0" +click = ">=7.1.1,<8.0.0" +persist-queue = ">=0.6.0,<0.7.0" +requests = ">=2.22.0,<3.0.0" + +[[package]] +name = "aw-core" +version = "0.5.4" +description = "Core library for ActivityWatch" +category = "main" +optional = false +python-versions = ">=3.7,<4.0" + +[package.dependencies] +appdirs = ">=1.4.3,<2.0.0" +deprecation = "*" +iso8601 = ">=0.1.12,<0.2.0" +jsonschema = ">=3.1,<4.0" +peewee = ">=3.0.0,<4.0.0" +python-json-logger = ">=0.1.11,<0.2.0" +strict-rfc3339 = ">=0.7,<0.8" +TakeTheTime = ">=0.3.1,<0.4.0" +timeslot = "*" +tomlkit = "*" + +[package.extras] +mongo = ["pymongo (>=3.10.0,<4.0.0)"] + +[[package]] +name = "certifi" +version = "2021.5.30" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "charset-normalizer" +version = "2.0.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "7.1.2" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "croniter" +version = "1.0.15" +description = "croniter provides iteration for datetime object with cron like format" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +python-dateutil = "*" + +[[package]] +name = "deprecation" +version = "2.1.0" +description = "A library to handle automated deprecations" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +packaging = "*" + +[[package]] +name = "idna" +version = "3.2" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "importlib-metadata" +version = "4.6.3" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +perf = ["ipython"] +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)"] + +[[package]] +name = "iso8601" +version = "0.1.16" +description = "Simple module to parse ISO 8601 dates" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "jsonschema" +version = "3.2.0" +description = "An implementation of JSON Schema validation for Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +attrs = ">=17.4.0" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +pyrsistent = ">=0.14.0" +six = ">=1.11.0" + +[package.extras] +format = ["idna", "jsonpointer (>1.13)", "rfc3987", "strict-rfc3339", "webcolors"] +format_nongpl = ["idna", "jsonpointer (>1.13)", "webcolors", "rfc3986-validator (>0.1.0)", "rfc3339-validator"] + +[[package]] +name = "loguru" +version = "0.5.3" +description = "Python logging made (stupidly) simple" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +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)"] + +[[package]] +name = "more-itertools" +version = "8.8.0" +description = "More routines for operating on iterables, beyond itertools" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "packaging" +version = "21.0" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2" + +[[package]] +name = "peewee" +version = "3.14.4" +description = "a little orm" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "persist-queue" +version = "0.6.0" +description = "A thread-safe disk based persistent queue in Python." +category = "main" +optional = false +python-versions = "*" + +[package.extras] +extra = ["msgpack (>=0.5.6)"] + +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +name = "py" +version = "1.10.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "pyrsistent" +version = "0.18.0" +description = "Persistent/Functional/Immutable data structures" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "pytest" +version = "5.4.3" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=17.4.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +more-itertools = ">=4.0.0" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.5.0" +wcwidth = "*" + +[package.extras] +checkqa-mypy = ["mypy (==v0.761)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-json-logger" +version = "0.1.11" +description = "A python library adding a json log formatter" +category = "main" +optional = false +python-versions = ">=2.7" + +[[package]] +name = "pyzenity" +version = "2.0.0" +description = "lightweight and full featured library to display dialogs with python." +category = "main" +optional = false +python-versions = "*" +develop = false + +[package.source] +type = "git" +url = "https://github.com/bcbernardo/Zenity.git" +reference = "ab46b78" +resolved_reference = "ab46b78ba0dc93c84202c8e0772df3b547c85fe7" + +[[package]] +name = "requests" +version = "2.26.0" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "strict-rfc3339" +version = "0.7" +description = "Strict, simple, lightweight RFC3339 functions" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "takethetime" +version = "0.3.1" +description = "Take The Time, a time-taking library for Python" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "timeout-decorator" +version = "0.5.0" +description = "Timeout decorator" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "timeslot" +version = "0.1.2" +description = "Data type for representing time slots with a start and end." +category = "main" +optional = false +python-versions = ">=3.6,<4.0" + +[[package]] +name = "tomlkit" +version = "0.7.2" +description = "Style preserving TOML library" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "typer" +version = "0.3.2" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +click = ">=7.1.1,<7.2.0" + +[package.extras] +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)"] +all = ["colorama (>=0.4.3,<0.5.0)", "shellingham (>=1.3.0,<2.0.0)"] +dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)"] +doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=5.4.0,<6.0.0)", "markdown-include (>=0.5.1,<0.6.0)"] + +[[package]] +name = "typing-extensions" +version = "3.10.0.0" +description = "Backported and Experimental Type Hints for Python 3.5+" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "unidecode" +version = "1.2.0" +description = "ASCII transliterations of Unicode text" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "urllib3" +version = "1.26.6" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "win32-setctime" +version = "1.0.3" +description = "A small Python utility to set file creation time on Windows" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"] + +[[package]] +name = "zipp" +version = "3.5.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +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"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.7" +content-hash = "40ab4278473a6f22e1084753bbfdc0f0ae7c644bd37ec8cebd70f890b6d15792" + +[metadata.files] +appdirs = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, + {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, +] +aw-client = [ + {file = "aw-client-0.5.4.tar.gz", hash = "sha256:d14642226773a59e90cc353f69146759765560d027adc40eb5e169989f0d7556"}, + {file = "aw_client-0.5.4-py3-none-any.whl", hash = "sha256:5a3a12ab6771cbc95c08fa631e069f058781aa8b66e5c1985ca51a87f8f38efe"}, +] +aw-core = [ + {file = "aw-core-0.5.4.tar.gz", hash = "sha256:0450eb4958330021d6e65a11da41e41e534d504fcde188114eb06fca6fb4ea20"}, + {file = "aw_core-0.5.4-py3-none-any.whl", hash = "sha256:0b3324b4f8913506c467e6519ceccacf05a39bc095217ce86a0a4e40460c1acc"}, +] +certifi = [ + {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, + {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"}, + {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"}, +] +click = [ + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +croniter = [ + {file = "croniter-1.0.15-py2.py3-none-any.whl", hash = "sha256:0f97b361fe343301a8f66f852e7d84e4fb7f21379948f71e1bbfe10f5d015fbd"}, + {file = "croniter-1.0.15.tar.gz", hash = "sha256:a70dfc9d52de9fc1a886128b9148c89dd9e76b67d55f46516ca94d2d73d58219"}, +] +deprecation = [ + {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"}, + {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"}, +] +idna = [ + {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, + {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, +] +importlib-metadata = [ + {file = "importlib_metadata-4.6.3-py3-none-any.whl", hash = "sha256:51c6635429c77cf1ae634c997ff9e53ca3438b495f10a55ba28594dd69764a8b"}, + {file = "importlib_metadata-4.6.3.tar.gz", hash = "sha256:0645585859e9a6689c523927a5032f2ba5919f1f7d0e84bd4533312320de1ff9"}, +] +iso8601 = [ + {file = "iso8601-0.1.16-py2.py3-none-any.whl", hash = "sha256:906714829fedbc89955d52806c903f2332e3948ed94e31e85037f9e0226b8376"}, + {file = "iso8601-0.1.16.tar.gz", hash = "sha256:36532f77cc800594e8f16641edae7f1baf7932f05d8e508545b95fc53c6dc85b"}, +] +jsonschema = [ + {file = "jsonschema-3.2.0-py2.py3-none-any.whl", hash = "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163"}, + {file = "jsonschema-3.2.0.tar.gz", hash = "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a"}, +] +loguru = [ + {file = "loguru-0.5.3-py3-none-any.whl", hash = "sha256:f8087ac396b5ee5f67c963b495d615ebbceac2796379599820e324419d53667c"}, + {file = "loguru-0.5.3.tar.gz", hash = "sha256:b28e72ac7a98be3d28ad28570299a393dfcd32e5e3f6a353dec94675767b6319"}, +] +more-itertools = [ + {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, + {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, +] +packaging = [ + {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, + {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, +] +peewee = [ + {file = "peewee-3.14.4.tar.gz", hash = "sha256:9e356b327c2eaec6dd42ecea6f4ddded025793dba906a3d065a0452e726c51a2"}, +] +persist-queue = [ + {file = "persist-queue-0.6.0.tar.gz", hash = "sha256:e73dd62545d37e519247d96368bfa5c510fde66999a338d6d2d44790dc10f89b"}, + {file = "persist_queue-0.6.0-py2.py3-none-any.whl", hash = "sha256:b7a6a6e642bed23076f03d15d08d87aebad32029f3e702cc10f5b86d6fbd0cb7"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +py = [ + {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, + {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, +] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pyrsistent = [ + {file = "pyrsistent-0.18.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f4c8cabb46ff8e5d61f56a037974228e978f26bfefce4f61a4b1ac0ba7a2ab72"}, + {file = "pyrsistent-0.18.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:da6e5e818d18459fa46fac0a4a4e543507fe1110e808101277c5a2b5bab0cd2d"}, + {file = "pyrsistent-0.18.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5e4395bbf841693eaebaa5bb5c8f5cdbb1d139e07c975c682ec4e4f8126e03d2"}, + {file = "pyrsistent-0.18.0-cp36-cp36m-win32.whl", hash = "sha256:527be2bfa8dc80f6f8ddd65242ba476a6c4fb4e3aedbf281dfbac1b1ed4165b1"}, + {file = "pyrsistent-0.18.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2aaf19dc8ce517a8653746d98e962ef480ff34b6bc563fc067be6401ffb457c7"}, + {file = "pyrsistent-0.18.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58a70d93fb79dc585b21f9d72487b929a6fe58da0754fa4cb9f279bb92369396"}, + {file = "pyrsistent-0.18.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4916c10896721e472ee12c95cdc2891ce5890898d2f9907b1b4ae0f53588b710"}, + {file = "pyrsistent-0.18.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:73ff61b1411e3fb0ba144b8f08d6749749775fe89688093e1efef9839d2dcc35"}, + {file = "pyrsistent-0.18.0-cp37-cp37m-win32.whl", hash = "sha256:b29b869cf58412ca5738d23691e96d8aff535e17390128a1a52717c9a109da4f"}, + {file = "pyrsistent-0.18.0-cp37-cp37m-win_amd64.whl", hash = "sha256:097b96f129dd36a8c9e33594e7ebb151b1515eb52cceb08474c10a5479e799f2"}, + {file = "pyrsistent-0.18.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:772e94c2c6864f2cd2ffbe58bb3bdefbe2a32afa0acb1a77e472aac831f83427"}, + {file = "pyrsistent-0.18.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c1a9ff320fa699337e05edcaae79ef8c2880b52720bc031b219e5b5008ebbdef"}, + {file = "pyrsistent-0.18.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cd3caef37a415fd0dae6148a1b6957a8c5f275a62cca02e18474608cb263640c"}, + {file = "pyrsistent-0.18.0-cp38-cp38-win32.whl", hash = "sha256:e79d94ca58fcafef6395f6352383fa1a76922268fa02caa2272fff501c2fdc78"}, + {file = "pyrsistent-0.18.0-cp38-cp38-win_amd64.whl", hash = "sha256:a0c772d791c38bbc77be659af29bb14c38ced151433592e326361610250c605b"}, + {file = "pyrsistent-0.18.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d5ec194c9c573aafaceebf05fc400656722793dac57f254cd4741f3c27ae57b4"}, + {file = "pyrsistent-0.18.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:6b5eed00e597b5b5773b4ca30bd48a5774ef1e96f2a45d105db5b4ebb4bca680"}, + {file = "pyrsistent-0.18.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:48578680353f41dca1ca3dc48629fb77dfc745128b56fc01096b2530c13fd426"}, + {file = "pyrsistent-0.18.0-cp39-cp39-win32.whl", hash = "sha256:f3ef98d7b76da5eb19c37fda834d50262ff9167c65658d1d8f974d2e4d90676b"}, + {file = "pyrsistent-0.18.0-cp39-cp39-win_amd64.whl", hash = "sha256:404e1f1d254d314d55adb8d87f4f465c8693d6f902f67eb6ef5b4526dc58e6ea"}, + {file = "pyrsistent-0.18.0.tar.gz", hash = "sha256:773c781216f8c2900b42a7b638d5b517bb134ae1acbebe4d1e8f1f41ea60eb4b"}, +] +pytest = [ + {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, + {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] +python-json-logger = [ + {file = "python-json-logger-0.1.11.tar.gz", hash = "sha256:b7a31162f2a01965a5efb94453ce69230ed208468b0bbc7fdfc56e6d8df2e281"}, +] +pyzenity = [] +requests = [ + {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, + {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +strict-rfc3339 = [ + {file = "strict-rfc3339-0.7.tar.gz", hash = "sha256:5cad17bedfc3af57b399db0fed32771f18fc54bbd917e85546088607ac5e1277"}, +] +takethetime = [ + {file = "TakeTheTime-0.3.1.tar.gz", hash = "sha256:dbe30453a1b596a38f9e2e3fa8e1adc5af2dbf646ca0837ad5c2059a16fe2ff9"}, +] +timeout-decorator = [ + {file = "timeout-decorator-0.5.0.tar.gz", hash = "sha256:6a2f2f58db1c5b24a2cc79de6345760377ad8bdc13813f5265f6c3e63d16b3d7"}, +] +timeslot = [ + {file = "timeslot-0.1.2-py3-none-any.whl", hash = "sha256:2f8efaec7b0a4c1e56a92ec05533219332dd9d8b577539077664c233996911b5"}, + {file = "timeslot-0.1.2.tar.gz", hash = "sha256:a2ac998657e3f3b9ca928757b4906add2c05390c5fc14ed792bb9028d08547b1"}, +] +tomlkit = [ + {file = "tomlkit-0.7.2-py2.py3-none-any.whl", hash = "sha256:173ad840fa5d2aac140528ca1933c29791b79a374a0861a80347f42ec9328117"}, + {file = "tomlkit-0.7.2.tar.gz", hash = "sha256:d7a454f319a7e9bd2e249f239168729327e4dd2d27b17dc68be264ad1ce36754"}, +] +typer = [ + {file = "typer-0.3.2-py3-none-any.whl", hash = "sha256:ba58b920ce851b12a2d790143009fa00ac1d05b3ff3257061ff69dbdfc3d161b"}, + {file = "typer-0.3.2.tar.gz", hash = "sha256:5455d750122cff96745b0dec87368f56d023725a7ebc9d2e54dd23dc86816303"}, +] +typing-extensions = [ + {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, + {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, + {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, +] +unidecode = [ + {file = "Unidecode-1.2.0-py2.py3-none-any.whl", hash = "sha256:12435ef2fc4cdfd9cf1035a1db7e98b6b047fe591892e81f34e94959591fad00"}, + {file = "Unidecode-1.2.0.tar.gz", hash = "sha256:8d73a97d387a956922344f6b74243c2c6771594659778744b2dbdaad8f6b727d"}, +] +urllib3 = [ + {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"}, + {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, +] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] +win32-setctime = [ + {file = "win32_setctime-1.0.3-py3-none-any.whl", hash = "sha256:dc925662de0a6eb987f0b01f599c01a8236cb8c62831c22d9cada09ad958243e"}, + {file = "win32_setctime-1.0.3.tar.gz", hash = "sha256:4e88556c32fdf47f64165a2180ba4552f8bb32c1103a2fafd05723a0bd42bd4b"}, +] +zipp = [ + {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"}, + {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8f6dc4d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[tool.poetry] +name = "aw-watcher-ask" +version = "0.1.0" +description = "An ActivityWatch watcher to randomly pose questions to the user." +authors = ["bcbernardo "] + +[tool.poetry.scripts] +aw-watcher-ask = "aw_watcher_ask.cli:app" + +[tool.poetry.dependencies] +python = "^3.7" +typer = "^0.3.2" +aw-client = "^0.5.4" +croniter = "^1.0.15" +loguru = "^0.5.3" +Unidecode = "^1.2.0" +timeout-decorator = "^0.5.0" +pyzenity = {git = "https://github.com/bcbernardo/Zenity.git", rev="ab46b78"} + +[tool.poetry.dev-dependencies] +pytest = "^5.2" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/src/aw_watcher_ask/__init__.py b/src/aw_watcher_ask/__init__.py new file mode 100644 index 0000000..b88434a --- /dev/null +++ b/src/aw_watcher_ask/__init__.py @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: Bernardo Chrispim Baron +# +# SPDX-License-Identifier: MIT + + +__version__ = "0.1.0" diff --git a/src/aw_watcher_ask/__main__.py b/src/aw_watcher_ask/__main__.py new file mode 100644 index 0000000..c84e0d1 --- /dev/null +++ b/src/aw_watcher_ask/__main__.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +# +# SPDX-FileCopyrightText: Bernardo Chrispim Baron +# +# SPDX-License-Identifier: MIT + + +"""Main entrypoint to aw-watcher ask.""" + + +from aw_watcher_ask.cli import app + + +if __name__ == "__main__": + app(prog_name="aw-input-watcher") diff --git a/src/aw_watcher_ask/cli.py b/src/aw_watcher_ask/cli.py new file mode 100644 index 0000000..8de25a8 --- /dev/null +++ b/src/aw_watcher_ask/cli.py @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: Bernardo Chrispim Baron +# +# SPDX-License-Identifier: MIT + + +"""A command-line interface (CLI) to aw-watcher-ask.""" + + +from datetime import datetime +from typing import Dict, List, Optional, Union + +import typer + +from aw_watcher_ask import __version__ +from aw_watcher_ask.core import main +from aw_watcher_ask.models import DialogType + + +app = typer.Typer() + + +def _parse_extra_args( + extra_args: List[str] +) -> Dict[str, Union[bool, str, List[str]]]: + """Processes any number of unknown CLI arguments and/or options. + + Arguments: + extra_args: A list of unprocessed arguments and/or options forwarded + by a Click/Typer command-line application. + + Returns: + A dictionary of option names and values. + """ + + options: Dict[str, Union[bool, str, List[str]]] = dict() + + # iterate over unparsed options + for ix in range(0, len(extra_args)): + + # check whether the element in this position starts with an option name + if extra_args[ix].startswith("-"): + + # if it is, remove it from un parsed args and split the option name + # and an optional value (if format `--name=value` was used) + option_name, *option_values = extra_args.pop(ix).split("=", 1) + option_name = option_name.lstrip("-") + + if not option_values: + # no value in `=`-separated value: keep parsing for + # (possibly multiple) values, provided in the format + # `--name option1 option 2` + while True: + if extra_args[ix].startswith("-"): + # found the next option name; stop looking for values + break + else: + # is a value; remove it from unparsed args and store it + option_values.append(extra_args.pop(ix)) + + # have any value been found? + if len(option_values) == 0: + # no: assume option was a flag, and store True + options[option_name] = True + elif len(option_values) == 1: + # yes, one value has been found: unpack it and store it + options[option_name] = option_values[0] + else: + # multiple values have been found: store them as a list + options[option_name] = option_values + + return options + + +@app.callback(invoke_without_command=True) +def callback( + ctx: typer.Context, + version: Optional[bool] = typer.Option( + False, "--version", help="Show program version.", show_default=False + ), +): + """Gathers user's inputs and send them to ActivityWatch. + + This watcher periodically presents a dialog box to the user, and stores the + provided answer on the locally running ActivityWatch server. It relies on + Zenity to construct simple graphic interfaces. + """ + if version and ctx.invoked_subcommand is None: + typer.echo(__version__) + typer.Exit() + + +@app.command(context_settings={ + "allow_extra_args": True, + "ignore_unknown_options": True, + "allow_interspersed_args": False, +}) +def run( + ctx: typer.Context, + question_type: DialogType = typer.Option(..., help=( + "The type of dialog box to present the user." + )), + question_id: str = typer.Option(..., help=( + "A short string to identify your question in ActivityWatch " + "server records. Should contain only lower-case letters, numbers and " + "dots. If `--title` is not provided, this will also be the " + "key to identify the content of the answer in the ActivityWatch " + "bucket's raw data." + )), + title: Optional[str] = typer.Option(None, help=( + "An optional title for the question. If provided, this will be both " + "the title of the dialog box and the key that identifies the content " + "of the answer in the ActivityWatch bucket's raw data." + )), + schedule: str = typer.Option("R * * * *", help=( + "A cron-tab expression (see https://en.wikipedia.org/wiki/Cron) " + "that controls the execution intervals at which the user should be " + "prompted to answer the given question. Accepts 'R' as a keyword at " + "second, minute and hour positions, for prompting at random times." + "Might be a classic five-element expression, or optionally have a " + "sixth element to indicate the seconds." + )), + until: datetime = typer.Option("2100-12-31", help=( + "A date and time when to stop gathering input from the user." + )), + timeout: int = typer.Option( + 60, help="The amount of seconds to wait for user's input." + ), + testing: bool = typer.Option( + False, help="If set, starts ActivityWatch Client in testing mode." + ), +): + params = locals().copy() + params.pop("ctx", None) + params = dict(params, **_parse_extra_args(ctx.args)) + main(**params) diff --git a/src/aw_watcher_ask/core.py b/src/aw_watcher_ask/core.py new file mode 100644 index 0000000..679945f --- /dev/null +++ b/src/aw_watcher_ask/core.py @@ -0,0 +1,194 @@ +# SPDX-FileCopyrightText: Bernardo Chrispim Baron +# +# SPDX-License-Identifier: MIT + + +"""Watcher function and helpers.""" + + +import sys +import time +from datetime import datetime +from typing import Any, Dict, Optional + +import zenity +from aw_client import ActivityWatchClient +from aw_core.models import Event +from croniter import croniter +from loguru import logger + +from aw_watcher_ask.models import DialogType +from aw_watcher_ask.utils import fix_id, is_valid_id, get_current_datetime + + +def _bucket_setup(client: ActivityWatchClient, question_id: str) -> str: + """Makes sure a bucket exists in the client for the given event type.""" + + bucket_id = "{}_{}".format(client.client_name, client.client_hostname) + client.create_bucket(bucket_id, event_type=question_id) + + return bucket_id + + +def _client_setup(testing: bool = False) -> ActivityWatchClient: + """Builds a new ActivityWatcher client instance and bucket.""" + + # set client name + client_name = "aw-watcher-ask" + if testing: + client_name = "test-" + client_name + + # create client representation + return ActivityWatchClient(client_name, testing=testing) + + +def _ask_one( + question_type: DialogType, title: str, *args, **kwargs +) -> Dict[str, Any]: + """Captures an user's response to a dialog box with a single field.""" + kwargs.pop("ctx", None) + success, content = zenity.show( + question_type.value, title=title, *args, **kwargs + ) + return { + "success": success, + title: content, + } + + +def _ask_many( + question_type: DialogType, separator: str = "|", *args, **kwargs +) -> Dict[str, Any]: + """Captures the user's response to a dialog box with multiple fields.""" + raise NotImplementedError + + +def main( + question_id: str, + question_type: DialogType = DialogType.question, + title: Optional[str] = None, + schedule: str = "R * * * *", + until: datetime = datetime(2100, 12, 31), + timeout: int = 60, + testing: bool = False, + *args, + **kwargs, +) -> None: + """Gathers user's inputs and send them to ActivityWatch. + + This watcher periodically presents a dialog box to the user, and stores the + provided answer on the locally running [ActivityWatch] + (https://docs.activitywatch.net/) server. It relies on [Zenity] + (https://help.gnome.org/users/zenity/stable/index.html.en) to construct + simple graphic interfaces. + + Arguments: + question_id: A short string to identify your question in ActivityWatch + server records. Should contain only lower-case letters, numbers and + dots. If `title` is not provided, this will also be the + key to identify the content of the answer in the ActivityWatch + bucket's raw data. + question_type: The type of dialog box to present the user, provided as + one of [`aw_watcher_ask.models.DialogType`] + [aw_watcher_ask.models.DialogType] enumeration types. Currently, + `DialogType.forms`, `DialogType.list` and + `DialogType.file_selection` are not supported. Defaults to + `DialogType.question`. + title: An optional title for the question. If provided, this + will be both the title of the dialog box and the key that + identifies the content of the answer in the ActivityWatch bucket's + raw data. + schedule: A [cron-tab expression](https://en.wikipedia.org/wiki/Cron) + that controls the execution intervals at which the user should be + prompted to answer the given question. Accepts 'R' as a keyword at + second, minute and hour positions, for prompting at random times. + Might be a classic five-element expression, or optionally have a + sixth element to indicate the seconds. + until: A [`datetime.datetime`] + (https://docs.python.org/3/library/datetime.html#datetime-objects) + object, that indicates the date and time when to stop gathering + input from the user. Defaults to `datetime(2100, 12, 31)`. + timeout: The amount of seconds to wait for user's input. Defaults to + 60 seconds. + testing: Whether to run the [`aw_client.ActivityWatchClient`] + (https://docs.activitywatch.net/en/latest/api/python.html + #aw_client.ActivityWatchClient) client in testing mode. + *args: Variable lenght argument list to be passed to [`zenity.show()`] + (https://pyzenity.gitbook.io/docs/) Zenity wrapper. + **kwargs: Variable lenght argument list to be passed to + [`zenity.show()`](https://pyzenity.gitbook.io/docs/) Zenity + wrapper. + + Raises: + NotImplementedError: If the provided `question_type` is one of + `DialogType.forms`, `DialogType.list` or + `DialogType.file_selection`. + """ + + log_format = "{time} <{extra[question_id]}>: {level} - {message}" + logger.add(sys.stderr, level="INFO", format=log_format) + log = logger.bind(question_id=question_id) + + log.info("Starting new watcher...") + + # fix question-id if it was provided with forbidden characters + if not is_valid_id(question_id): + question_id = fix_id(question_id) + log.warning( + f"An invalid question_id was provided. Fixed to `{question_id}`." + ) + log = log.bind(question_id=question_id) + + # fix offset-naive datetimes + if not until.tzinfo: + system_timezone = get_current_datetime().astimezone().tzinfo + until = until.replace(tzinfo=system_timezone) + + # start client and bucket + client = _client_setup(testing=testing) + log.info( + f"Client created and connected to server at {client.server_address}." + ) + bucket_id = _bucket_setup(client, question_id) + + # execution schedule + executions = croniter(schedule, start_time=get_current_datetime()) + + # run service + while get_current_datetime() < until: + # wait until next execution + next_execution = executions.get_next(datetime) + log.info( + f"Next execution scheduled to {next_execution.isoformat()}." + ) + sleep_time = next_execution - get_current_datetime() + time.sleep(max(sleep_time.seconds, 0)) + + log.info( + "New prompt fired. Waiting for user input..." + ) + if question_type.value in ["forms", "file-selection", "list"]: + # TODO: not implemented + answer = _ask_many( + question_type=question_type, + title=title, + timeout=timeout, + *args, + **kwargs, + ) + else: + answer = _ask_one( + question_type=question_type, + title=( + title if title else question_id + ), + timeout=timeout, + *args, + **kwargs, + ) + if not answer["success"]: + log.info("Prompt timed out with no response from user.") + + event = Event(timestamp=get_current_datetime(), data=answer) + client.insert_event(bucket_id, event) + log.info(f"Event stored in bucket '{bucket_id}'.") diff --git a/src/aw_watcher_ask/models.py b/src/aw_watcher_ask/models.py new file mode 100644 index 0000000..a28d6a6 --- /dev/null +++ b/src/aw_watcher_ask/models.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Bernardo Chrispim Baron +# +# SPDX-License-Identifier: MIT + +"""Representations for exchanging data with Zenity and ActivityWatch.""" + + +from enum import Enum + + +class DialogType(str, Enum): + calendar = "calendar" # Display calendar dialog + entry = "entry" # Display text entry dialog + error = "error" # Display error dialog + info = "info" # Display info dialog + file_selection = "file-selection" # Display file selection dialog + list = "list" # Display list dialog + notification = "notification" # Display notification + progress = "progress" # Display progress indication dialog + warning = "warning" # Display warning dialog + scale = "scale" # Display scale dialog + text_info = "text-info" # Display text information dialog + color_selection = "color-selection" # Display color selection dialog + question = "question" # Display question dialog + password = "password" # Display password dialog + forms = "forms" # Display forms dialog diff --git a/src/aw_watcher_ask/utils.py b/src/aw_watcher_ask/utils.py new file mode 100644 index 0000000..3be4d79 --- /dev/null +++ b/src/aw_watcher_ask/utils.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Bernardo Chrispim Baron +# +# SPDX-License-Identifier: MIT + +"""General utilities for interacting with Zenity and ActivityWatch.""" + + +import re +from datetime import datetime, timezone + +from unidecode import unidecode + + +def fix_id(question_id: str) -> str: + """Replaces forbidden characters in a question_id.""" + return re.sub(r"[^a-z0-9]", ".", unidecode(question_id).lower()) + + +def is_valid_id(question_id: str) -> bool: + """Checks whether a given question_id contains only accepted characters.""" + return not bool(re.search(r"[^a-z0-9.]", question_id)) + + +def get_current_datetime() -> datetime: + """Returns the current UTC date and time.""" + return datetime.now(timezone.utc) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..19a1b65 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: Bernardo Chrispim Baron +# +# SPDX-License-Identifier: MIT diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4124224 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: Bernardo Chrispim Baron +# +# SPDX-License-Identifier: MIT + + +"""Tests running aw-watcher-input from the command-line interface.""" + + +import re +from datetime import datetime, timedelta + +import pytest +from typer.testing import CliRunner + +from aw_watcher_ask.cli import app + + +@pytest.fixture(scope="function") +def runner(): + """Provides a command-line test runner.""" + return CliRunner() + + +def test_version(runner): + """Tests getting app version.""" + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert re.match(r"[0-9]+\.[0-9]+\.[0-9]+", result.stdout) + + +@pytest.mark.parametrize("question_id", ["accepted.id", "Forbiddên_ID"]) +@pytest.mark.parametrize("question_type", ["question", "entry"]) +@pytest.mark.parametrize("schedule", ["* * * * * */4"]) +def test_app(runner, question_id, question_type, schedule): + end_time = datetime.now() + timedelta(seconds=9) + result = runner.invoke( + app, + [ + "run", + question_type, + "--testing", + "--id", + question_id, + "--schedule", + schedule, + "--until", + end_time.isoformat(timespec="seconds"), + "--timeout", + 2, + ], + ) + assert result.exit_code == 0 + assert "INFO - Starting new watcher" in result.output + assert "INFO - Client created" in result.output + assert "INFO - Next execution scheduled" in result.output + assert "INFO - New prompt fired" in result.output + assert "INFO - Prompt timed out" in result.output + assert "INFO - Event stored in bucket" in result.output diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..2b0a7b5 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: Bernardo Chrispim Baron +# +# SPDX-License-Identifier: MIT + + +"""Tests for aw-watcher-ask main logic.""" + + +from datetime import datetime, timedelta, timezone +from random import randint +from typing import Optional + +import pytest +from aw_client import ActivityWatchClient + +from aw_watcher_ask.core import ( + _ask_many, _ask_one, _client_setup, _bucket_setup, main +) +from aw_watcher_ask.models import DialogType + + +def test_client_setup(): + """Tests instantiating an ActivityWatch client object.""" + client = _client_setup(testing=True) + assert client.client_name == "test-aw-watcher-ask" + assert client.client_hostname == "localhost.localdomain" + info = client.get_info() + assert "hostname" in info + assert "testing" in info + with client: + assert True + client.connect() + client.disconnect() + + +def test_bucket_setup(): + """Tests creating and deleting a bucket""" + with ActivityWatchClient("test-client", testing=True) as client: + + # create bucket + new_bucket_id = _bucket_setup(client, question_id="test.question") + buckets = client.get_buckets() + assert any(bucket == new_bucket_id for bucket in buckets) + + # delete bucket + client.delete_bucket(new_bucket_id) + buckets = client.get_buckets() + assert not any(bucket == new_bucket_id for bucket in buckets) + + +def test_ask_question(): + """Tests asking a question with a single answer field to the user.""" + answer = _ask_one(DialogType("question"), "Test question", timeout=2) + assert "success" in answer + assert not answer["success"] + assert "Test question" in answer + assert len(answer["Test question"]) == 0 + + +def test_ask_many(): + """Tests asking a question with multiple answer fields to the user.""" + with pytest.raises(NotImplementedError): + _ask_many(DialogType("forms"), timeout=5) + + +@pytest.mark.parametrize("question_type", ["question"]) +@pytest.mark.parametrize("title", ["Test question", None]) +def test_main_one(question_type: str, title: Optional[str]): + """Tests periodically asking a single question and storing user's input.""" + with ActivityWatchClient("test-client", testing=True) as client: + question_id = "test.question" + str(randint(0, 10 ** 10)) + bucket_id = "test-aw-watcher-ask_localhost.localdomain" + try: + start_time = datetime.now(timezone.utc) + end_time = start_time + timedelta(seconds=9) + main( + question_type=DialogType("question"), + question_id=question_id, + title=title, + schedule="* * * * * */4", + until=end_time, + timeout=2, + testing=True, + ) + last_event = client.get_events(bucket_id=bucket_id, limit=1)[0] + assert last_event.timestamp > start_time + assert last_event.timestamp < end_time + timedelta(seconds=2) + assert "success" in last_event.data + assert not last_event.data["success"] + if not title: + assert question_id in last_event.data + assert len(last_event.data[question_id]) == 0 + else: + assert title in last_event.data + assert len(last_event.data[title]) == 0 + finally: + client.delete_bucket(bucket_id) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..b32df9e --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Bernardo Chrispim Baron +# +# SPDX-License-Identifier: MIT + + +"""Tests for the utilities to interact with Zenity and ActivityWatch.""" + +import pytest +from datetime import datetime + +from aw_watcher_ask.utils import fix_id, is_valid_id, get_current_datetime + + +@pytest.mark.parametrize("valid_id", ["a.correct.id"]) +def testis_valid_id(valid_id: str): + """Tests recognizing a valid event_type id.""" + assert is_valid_id(valid_id) + + +@pytest.mark.parametrize( + "invalid_id", + [ + "a string with spaces", + "a_string_with_underscores", + "ã.string.wïth.nonáscii.çhars", + "AN.UPPERCASE.STRING", + ], +) +def test_isnot_valid_id(invalid_id: str): + """Tests recognizing forbidden event_type ids.""" + assert not is_valid_id(invalid_id) + + +@pytest.mark.parametrize("valid_id", ["a.correct.id"]) +def testfix_valid_id(valid_id: str): + """Tests applying fix to an already correct event_type id.""" + transformed_id = fix_id(valid_id) + assert is_valid_id(transformed_id) + assert valid_id == transformed_id + + +@pytest.mark.parametrize( + "invalid_id", + [ + "a string with spaces", + "a_string_with_underscores", + "ã.string.wïth.nonáscii.çhars", + "AN.UPPERCASE.STRING", + ], +) +def testfix_invalid_id(invalid_id: str): + """Tests applying fix to event_type ids with incorrect .""" + assert is_valid_id(fix_id(invalid_id)) + + +def test_get_current_datetime(): + """Returns the current UTC date and time.""" + now = get_current_datetime() + assert isinstance(now, datetime) + assert now.tzname() == "UTC"