├── .github └── workflows │ ├── build.yml │ └── codeql.yml ├── .gitignore ├── .gitmodules ├── Makefile ├── README.md ├── aw_research ├── __about__.py ├── __init__.py ├── __main__.py ├── algorithmia.py ├── classify.py ├── filters.py ├── main.py ├── merge.py ├── plot.py ├── plot_sunburst.py ├── py.typed ├── redact.py ├── thankful.py ├── tree.py └── util.py ├── categories.example.csv ├── categories.example.toml ├── examples ├── afk_and_audible.py ├── pandas_example.py └── plot_timeperiods.py ├── parent_categories.csv.example ├── poetry.lock ├── pyproject.toml ├── queries └── aw-development.awq └── tests ├── test_all.py └── test_redact.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | name: Test on ${{ matrix.os }} 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest] #, windows-latest, macOS-latest] 16 | python_version: [3.8] 17 | steps: 18 | - uses: actions/checkout@v2 19 | with: 20 | submodules: 'recursive' 21 | - name: Set up Python 22 | uses: actions/setup-python@v1 23 | with: 24 | python-version: ${{ matrix.python_version }} 25 | - name: Create virtualenv 26 | shell: bash 27 | run: | 28 | pip install virtualenv 29 | python -m virtualenv venv 30 | - name: Install dependencies 31 | shell: bash 32 | run: | 33 | pip install poetry 34 | source venv/bin/activate || source venv/Scripts/activate 35 | make build 36 | - name: Run tests 37 | shell: bash 38 | run: | 39 | source venv/bin/activate || source venv/Scripts/activate 40 | make test 41 | make typecheck 42 | bash <(curl -s https://codecov.io/bash) 43 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "33 4 * * 3" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ python ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | *.*cache* 4 | *.egg-info 5 | private 6 | categories.csv 7 | categories.toml 8 | .coverage* 9 | htmlcov 10 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ActivityWatch/aw-research/680f240cbe8443950109965097744ea7eed181c2/.gitmodules -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build test run 2 | 3 | build: 4 | poetry install 5 | 6 | test: 7 | poetry run pytest --cov=aw_research tests/ aw_research/*.py 8 | 9 | test-integration: 10 | aw-research redact 11 | aw-research merge 12 | aw-research flood 13 | aw-research heartbeat 14 | aw-research classify summary 15 | aw-research classify cat Uncategorized 16 | python3 examples/afk_and_audible.py 17 | python3 examples/redact_sensitive.py 18 | make vis-aw-development 19 | #pipenv run python3 aw_research analyse 20 | 21 | typecheck: 22 | mypy --ignore-missing-imports --check-untyped-defs aw_research/ examples/ tests/ 23 | 24 | style-fix: 25 | black aw_research/ tests/ 26 | 27 | .cache-query-result: 28 | python3 -m aw_client --host localhost:5666 query --json --start 2018-01-01 queries/aw-development.awq > .cache-query-result 29 | 30 | vis-aw-development: .cache-query-result 31 | python3 examples/plot_timeperiods.py .cache-query-result 32 | 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | aw-research 2 | =========== 3 | 4 | [![Build Status](https://travis-ci.org/ActivityWatch/aw-research.svg?branch=master)](https://travis-ci.org/ActivityWatch/aw-research) 5 | 6 | Tools to analyse and experiment with ActivityWatch data. 7 | 8 | Some of the things developed here might become ActivityWatch features, and others are starting points for research as talked about in [this issue](https://github.com/ActivityWatch/activitywatch/issues/236). 9 | 10 | 11 | ## Usage 12 | 13 | ``` 14 | pipenv install 15 | pipenv run python3 -m aw_research --help 16 | ``` 17 | 18 | To use some of the analysis methods you need to create some configuration files manually, see the `.example` files for how they should look. 19 | -------------------------------------------------------------------------------- /aw_research/__about__.py: -------------------------------------------------------------------------------- 1 | # Inspired by: 2 | # https://github.com/pypa/pipfile/blob/master/pipfile/__about__.py 3 | 4 | __all__ = [ 5 | "__title__", 6 | "__summary__", 7 | "__uri__", 8 | "__version__", 9 | "__author__", 10 | "__email__", 11 | "__license__", 12 | "__copyright__", 13 | ] 14 | 15 | __title__ = "aw-research" 16 | __summary__ = "Research tools for ActivityWatch data" 17 | __uri__ = "https://github.com/ActivityWatch/aw-research" 18 | 19 | __version__ = "0.1.0" 20 | 21 | __author__ = "Erik Bjäreholt, Johan Bjäreholt" 22 | __email__ = "erik@bjareho.lt, johan@bjareho.lt" 23 | 24 | __license__ = "MPL2" 25 | __copyright__ = "Copyright 2017 %s" % __author__ 26 | -------------------------------------------------------------------------------- /aw_research/__init__.py: -------------------------------------------------------------------------------- 1 | from . import redact 2 | from . import merge 3 | from . import tree 4 | from .util import ( 5 | split_event_on_time, 6 | next_hour, 7 | split_event_on_hour, 8 | start_of_day, 9 | end_of_day, 10 | get_week_start, 11 | is_in_same_week, 12 | split_into_weeks, 13 | split_into_days, 14 | verify_no_overlap, 15 | categorytime_per_day, 16 | categorytime_during_day, 17 | ) # noqa 18 | -------------------------------------------------------------------------------- /aw_research/__main__.py: -------------------------------------------------------------------------------- 1 | import aw_research.main 2 | 3 | 4 | if __name__ == "__main__": 5 | aw_research.main.main() 6 | -------------------------------------------------------------------------------- /aw_research/algorithmia.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from typing import List 4 | 5 | try: 6 | import Algorithmia 7 | except ImportError: 8 | pass 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | API_KEY = ( 13 | os.environ["ALGORITHMIA_API_KEY"] if "ALGORITHMIA_API_KEY" in os.environ else None 14 | ) 15 | 16 | 17 | def _assert_api_key(): 18 | if API_KEY is None: 19 | raise Exception( 20 | "Env variable ALGORITHMIA_API_KEY not set, can't use Algorithmia." 21 | ) 22 | 23 | 24 | def run_sentiment(docs: List[str]): 25 | _assert_api_key() 26 | payload = [{"document": doc} for doc in docs] 27 | client = Algorithmia.client(API_KEY) 28 | algo = client.algo("nlp/SentimentAnalysis/1.0.3") 29 | return algo.pipe(payload) 30 | 31 | 32 | def run_LDA(docs: List[str]): 33 | _assert_api_key() 34 | payload = { 35 | "docsList": docs, 36 | "mode": "quality", 37 | "stopWordsList": ["/"], 38 | } 39 | client = Algorithmia.client(API_KEY) 40 | algo = client.algo("nlp/LDA/1.0.0") 41 | return algo.pipe(payload) 42 | -------------------------------------------------------------------------------- /aw_research/classify.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import logging 4 | import re 5 | import typing 6 | from collections import Counter 7 | from datetime import datetime, timedelta, timezone 8 | from functools import wraps 9 | from typing import ( 10 | Dict, 11 | List, 12 | Optional, 13 | Set, 14 | Tuple, 15 | ) 16 | from urllib.parse import urlparse 17 | 18 | import joblib 19 | import matplotlib.pyplot as plt 20 | import pandas as pd 21 | import pydash 22 | import pytz 23 | import toml 24 | from aw_client import ActivityWatchClient 25 | from aw_core.models import Event 26 | from aw_transform import filter_period_intersect, flood, union_no_overlap 27 | 28 | from .plot_sunburst import sunburst 29 | 30 | logger = logging.getLogger(__name__) 31 | memory = joblib.Memory("./.cache/joblib") 32 | 33 | 34 | def _read_class_csv(filename) -> List[Tuple[str, str, Optional[str]]]: 35 | with open(filename) as f: 36 | classes = [] 37 | for line in f.readlines(): 38 | if line.strip() and not line.startswith("#"): 39 | re, cat, parent = (line.strip().split(";") + [""])[:3] 40 | classes.append((re, cat, parent or None)) 41 | return classes 42 | 43 | 44 | def test_read_class_csv(): 45 | assert _read_class_csv("categories.example.csv") 46 | 47 | 48 | def _read_class_toml(filename) -> List[Tuple[str, str, Optional[str]]]: 49 | classes = [] 50 | with open(filename) as f: 51 | data = toml.load(f) 52 | 53 | def _register_category_object(d, cat_name=None, parent=None): 54 | """Recursively registers categories""" 55 | if isinstance(d, str): 56 | assert cat_name 57 | classes.append((d, cat_name, parent)) 58 | elif isinstance(d, dict): 59 | for k in d: 60 | if k == "$re": 61 | # Handle this case last 62 | continue 63 | _register_category_object(d[k], cat_name=k, parent=cat_name) 64 | if "$re" in d: 65 | _register_category_object(d["$re"], cat_name=cat_name, parent=parent) 66 | d.pop("$re") 67 | 68 | _register_category_object(data["categories"]) 69 | return classes 70 | 71 | 72 | def test_read_class_toml(): 73 | assert _read_class_toml("categories.example.toml") 74 | 75 | 76 | classes: Optional[List[Tuple[str, str, Optional[str]]]] = None 77 | parent_categories: Optional[Dict[str, str]] = None 78 | 79 | 80 | def _init_classes( 81 | filename: str = None, new_classes: List[Tuple[str, str, Optional[str]]] = None 82 | ): 83 | global classes, parent_categories 84 | 85 | if filename and filename.endswith("csv"): 86 | classes = _read_class_csv(filename) 87 | elif filename and filename.endswith("toml"): 88 | classes = _read_class_toml(filename) 89 | elif new_classes: 90 | classes = new_classes 91 | else: 92 | raise Exception 93 | 94 | assert classes 95 | parent_categories = {tag: parent for _, tag, parent in classes if parent} 96 | 97 | 98 | def requires_init_classes(f): 99 | @wraps(f) 100 | def g(*args, **kwargs): 101 | if parent_categories is None: 102 | raise Exception("Classes not initialized, run _init_classes first.") 103 | return f(*args, **kwargs) 104 | 105 | return g 106 | 107 | 108 | @requires_init_classes 109 | def get_parent_categories(cat: str) -> Set[str]: 110 | assert parent_categories # just to quiet typechecker, checked by decorator 111 | 112 | # Recursive 113 | if cat in parent_categories: 114 | cats = {parent_categories[cat]} 115 | for parent in tuple(cats): 116 | cats |= get_parent_categories(parent) 117 | return cats 118 | return set() 119 | 120 | 121 | hier_sep = "->" 122 | 123 | 124 | @requires_init_classes 125 | def build_category_hierarchy(cat: str, app: str = None) -> str: 126 | assert parent_categories # just to quiet typechecker, checked by decorator 127 | 128 | # Recursive 129 | s = cat 130 | if cat in parent_categories: 131 | parent = parent_categories[cat] 132 | parents_of_parent = build_category_hierarchy(parent) 133 | if parents_of_parent: 134 | s = f"{parents_of_parent} {hier_sep} {s}" 135 | if app: 136 | app = app.lstrip("www.").rstrip(".com") 137 | if app.lower() not in s.lower(): 138 | s = f"{s} {hier_sep} {app}" 139 | return s 140 | 141 | 142 | @requires_init_classes 143 | def classify( 144 | events: List[Event], include_app=False, max_category_depth=3 145 | ) -> List[Event]: 146 | assert classes # just to quiet typechecker, checked by decorator 147 | 148 | for e in events: 149 | e.data["$tags"] = set() 150 | e.data["$category_hierarchy"] = "Uncategorized" 151 | 152 | for re_pattern, cat, _ in classes: 153 | try: 154 | r = re.compile(re_pattern) 155 | except Exception: 156 | logger.warning(f"Failed to compile regex for {cat}: {re_pattern}") 157 | continue 158 | 159 | for e in events: 160 | for attr in ["title", "app", "url"]: 161 | if attr not in e.data: 162 | continue 163 | if cat not in e.data["$tags"] and r.findall(e.data[attr]): 164 | e.data["$tags"].add(cat) 165 | e.data["$tags"] |= get_parent_categories(cat) 166 | 167 | for e in events: 168 | app = e.data.get("app", None) if include_app else None 169 | for cat in e.data["$tags"]: 170 | # Always assign the deepest category 171 | new_cat_hier = build_category_hierarchy(cat, app=app) 172 | if "$category_hierarchy" in e.data: 173 | old_cat_hier = e.data["$category_hierarchy"] 174 | if old_cat_hier.count(hier_sep) >= new_cat_hier.count(hier_sep): 175 | continue 176 | e.data["$category_hierarchy"] = new_cat_hier 177 | 178 | # Restrict maximum category depth 179 | e.data["$category_hierarchy"] = _restrict_category_depth( 180 | e.data["$category_hierarchy"], max_category_depth 181 | ) 182 | 183 | for e in events: 184 | if not e.data["$tags"]: 185 | e.data["$tags"].add("Uncategorized") 186 | 187 | return events 188 | 189 | 190 | def _hostname(url: str) -> str: 191 | return urlparse(url).netloc 192 | 193 | 194 | def group_by_url_hostname(events: List[Event]) -> Dict[str, List[Event]]: 195 | return pydash.group_by(events, lambda e: _hostname(e.data["url"])) 196 | 197 | 198 | def unfold_hier(s: str) -> List[str]: 199 | cats = s.split(" -> ") 200 | cats_s = [] 201 | for i in range(1, len(cats) + 1): 202 | cats_s.append(" -> ".join(cats[:i])) 203 | return cats_s 204 | 205 | 206 | def time_per_category(events: List[Event], unfold=True) -> typing.Counter[str]: 207 | c: typing.Counter[str] = Counter() 208 | for e in events: 209 | if unfold: 210 | cats = unfold_hier(e.data["$category_hierarchy"]) 211 | else: 212 | cats = [e.data["$category_hierarchy"]] 213 | for cat in cats: 214 | # FIXME: This will be wrong when subcategories with the same name exist with different parents 215 | c[cat] += e.duration.total_seconds() 216 | return c 217 | 218 | 219 | def _plot_category_hierarchy_sunburst(events): 220 | counter = time_per_category(events, unfold=False) 221 | data = {} 222 | for cat in counter: 223 | cat_levels = cat.split(" -> ") 224 | level = data 225 | for c in cat_levels: 226 | if c not in level: 227 | level[c] = {"time": 0, "subcats": {}} 228 | level[c]["time"] += counter[cat] 229 | level = level[c]["subcats"] 230 | 231 | def dict_hier_to_list_hier(d): 232 | return sorted( 233 | [ 234 | (k, v["time"], dict_hier_to_list_hier(v["subcats"])) 235 | for k, v in d.items() 236 | ], 237 | key=lambda t: -t[1], 238 | ) 239 | 240 | data = dict_hier_to_list_hier(data) 241 | 242 | sunburst(data, total=sum(t[1] for t in data)) 243 | plt.subplots_adjust(0, 0, 1, 1) 244 | 245 | 246 | def _restrict_category_depth(s: str, n: int) -> str: 247 | return " -> ".join(s.split(" -> ")[:n]) 248 | 249 | 250 | def time_per_app(events): 251 | c = Counter() 252 | for e in events: 253 | if "app" in e.data: 254 | c[e.data["app"]] += e.duration.total_seconds() 255 | return c 256 | 257 | 258 | def query2ify(f) -> str: 259 | """Decorator that transforms a Python function into query2 strings using inspection""" 260 | import inspect 261 | 262 | srclines = inspect.getsource(f).split("\n") 263 | # remove decoration and function definition 264 | srclines = srclines[2:] 265 | # remove comments (as query2 doesn't yet support them) 266 | srclines = [ln.split("#")[0] for ln in srclines] 267 | # remove indentation 268 | srclines = [ln.strip() for ln in srclines] 269 | # remove blank lines 270 | srclines = [ln for ln in srclines if ln] 271 | # remove import statements 272 | srclines = [ 273 | ln for ln in srclines if not (ln.startswith("import") or ln.startswith("from")) 274 | ] 275 | # replace `return ...` with `RETURN = ...` 276 | srclines = [ 277 | ln if "return" not in ln else ln.replace("return", "RETURN = ") 278 | for ln in srclines 279 | ] 280 | return ";\n".join(srclines) + ";" 281 | 282 | 283 | def build_query(hostname: str): 284 | query = _query_complete 285 | query = query.replace('hostname = ""', f'hostname = "{hostname}"') 286 | return query 287 | 288 | 289 | # The following function is later turned into a query string through introspection. 290 | # Fancy logic will obviously not work either. 291 | # TODO: This doesn't correctly handle web buckets since they don't have a hostname set 292 | # NOTE: fmt: off is used since query2ify assumes single-line statements 293 | # fmt: off 294 | @query2ify 295 | def _query_complete(): # noqa 296 | from aw_transform import (query_bucket, find_bucket, filter_keyvals, exclude_keyvals, period_union, concat) 297 | 298 | hostname = "" # set in preprocessing 299 | 300 | browsernames_chrome = ["Chromium"] # TODO: Include more browsers 301 | browsernames_ff = ["Firefox"] # TODO: Include more browsers 302 | 303 | events = flood(query_bucket(find_bucket("aw-watcher-window", hostname))) 304 | events_afk = query_bucket(find_bucket("aw-watcher-afk", hostname)) # TODO: Readd flooding for afk-events once a release has been made that includes the flooding-fix 305 | 306 | # Web stuff is commented out since I don't sync them to my sync testing aw-server instance 307 | #events_web_chrome = flood(query_bucket(find_bucket("aw-watcher-web-chrome"))) 308 | #events_web_ff = flood(query_bucket(find_bucket("aw-watcher-web-firefox"))) 309 | 310 | # Combine window events with web events 311 | #events_browser_chrome = filter_keyvals(events, "app", browsernames_chrome) 312 | #events_web_chrome = filter_period_intersect(events_web_chrome, events_browser_chrome) 313 | 314 | #events_browser_ff = filter_keyvals(events, "app", browsernames_ff) 315 | #events_web_ff = filter_period_intersect(events_web_ff, events_browser_ff) 316 | 317 | #events_web = concat(events_web_chrome, events_web_ff) 318 | 319 | # TODO: Browser events should only be excluded when there's a web-event replacing it 320 | #events = exclude_keyvals(events, "app", browsernames_chrome) 321 | #events = exclude_keyvals(events, "app", browsernames_ff) 322 | #events = concat(events, events_web) 323 | 324 | # Filter away all inactive (afk and non-audible) time 325 | events_notafk = filter_keyvals(events_afk, "status", ["not-afk"]) 326 | #events_audible = filter_keyvals(events_web, "audible", [True]) 327 | #events_active = period_union(events_notafk, events_audible) 328 | events_active = events_notafk 329 | events = filter_period_intersect(events, events_active) 330 | 331 | return events 332 | # fmt: on 333 | 334 | 335 | def _get_events_toggl(since: datetime, filepath: str) -> List[Event]: 336 | with open(filepath, "r", encoding="utf-8-sig") as f: 337 | lines = f.readlines() 338 | rows = [l.strip().split(",") for l in lines] 339 | header = rows[0] 340 | rows = rows[1:] 341 | entries: List[Dict] = [{"data": dict(zip(header, row))} for row in rows] 342 | for e in entries: 343 | for s in ["Start", "End"]: 344 | yyyy, mm, dd = map(int, e["data"].pop(f"{s} date").split("-")) 345 | HH, MM, SS = map(int, e["data"].pop(f"{s} time").split(":")) 346 | e["data"][s] = datetime(yyyy, mm, dd, HH, MM, SS).astimezone( 347 | pytz.timezone("Europe/Stockholm") 348 | ) 349 | e["timestamp"] = e["data"].pop("Start") 350 | e["duration"] = e["data"].pop("End") - e["timestamp"] 351 | del e["data"]["User"] 352 | del e["data"]["Email"] 353 | del e["data"]["Duration"] 354 | 355 | e["data"]["app"] = e["data"]["Project"] 356 | e["data"]["title"] = e["data"]["Description"] 357 | 358 | events = [Event(**e) for e in entries] 359 | events = [e for e in events if since.astimezone(timezone.utc) < e.timestamp] 360 | return events 361 | 362 | 363 | def _get_events_smartertime(since: datetime, filepath: str = "auto") -> List[Event]: 364 | # TODO: Use quantifiedme.load.smartertime to generate json file if filepath is smartertime export (.csv) 365 | # NOTE: deprecated, use methods in quantifiedme instead 366 | if filepath == "auto": 367 | from glob import glob 368 | 369 | filepath = sorted(glob("data/private/smartertime_export_*.awbucket.json"))[-1] 370 | 371 | print(f"Loading smartertime data from {filepath}") 372 | with open(filepath) as f: 373 | data = json.load(f) 374 | events = [Event(**e) for e in data["events"]] 375 | 376 | # Filter out events before `since` 377 | events = [e for e in events if since.astimezone(timezone.utc) < e.timestamp] 378 | 379 | # Filter out no-events and non-phone events 380 | events = [ 381 | e for e in events if any(s in e.data["activity"] for s in ["phone:", "call:"]) 382 | ] 383 | 384 | # Normalize to window-bucket data schema 385 | for e in events: 386 | e.data["app"] = e.data["activity"] 387 | e.data["title"] = e.data["app"] 388 | 389 | return events 390 | 391 | 392 | @memory.cache(ignore=["awc"]) 393 | def get_events( 394 | awc: ActivityWatchClient, 395 | hostname: str, 396 | since: datetime, 397 | end: datetime, 398 | include_smartertime="auto", 399 | include_toggl=None, 400 | ) -> List[Event]: 401 | query = build_query(hostname) 402 | logger.debug(f"Query:\n{query}") 403 | 404 | result = awc.query(query, timeperiods=[(since, end)]) 405 | events = [Event(**e) for e in result[0]] 406 | 407 | if include_smartertime: 408 | events = union_no_overlap( 409 | events, _get_events_smartertime(since, filepath=include_smartertime) 410 | ) 411 | events = sorted(events, key=lambda e: e.timestamp) 412 | 413 | if include_toggl: 414 | events = union_no_overlap( 415 | events, _get_events_toggl(since, filepath=include_toggl) 416 | ) 417 | events = sorted(events, key=lambda e: e.timestamp) 418 | 419 | # Filter by time 420 | events = [ 421 | e 422 | for e in events 423 | if since.astimezone(timezone.utc) < e.timestamp 424 | and e.timestamp + e.duration < end.astimezone(timezone.utc) 425 | ] 426 | assert all(since.astimezone(timezone.utc) < e.timestamp for e in events) 427 | assert all(e.timestamp + e.duration < end.astimezone(timezone.utc) for e in events) 428 | 429 | # Filter out events without data (which sometimes happens for whatever reason) 430 | events = [e for e in events if e.data] 431 | 432 | for event in events: 433 | if "app" not in event.data: 434 | if "url" in event.data: 435 | event.data["app"] = urlparse(event.data["url"]).netloc 436 | else: 437 | print("Unexpected event: ", event) 438 | 439 | events = [e for e in events if e.data] 440 | return events 441 | 442 | 443 | def test_hostname(): 444 | assert _hostname("http://activitywatch.net/") == "activitywatch.net" 445 | assert _hostname("https://github.com/") == "github.com" 446 | 447 | 448 | def _print_category(events, cat="Uncategorized", n=10): 449 | print(f"Showing top {n} from category: {cat}") 450 | events = [ 451 | e for e in sorted(events, key=lambda e: -e.duration) if cat in e.data["$tags"] 452 | ] 453 | print(f"Total time: {sum((e.duration for e in events), timedelta(0))}") 454 | groups = { 455 | k: (v[0].data, sum((e.duration for e in v), timedelta(0))) 456 | for k, v in pydash.group_by( 457 | events, lambda e: e.data.get("title", "unknown") 458 | ).items() 459 | } 460 | for _, (v, duration) in list(sorted(groups.items(), key=lambda g: -g[1][1]))[:n]: 461 | print(str(duration).split(".")[0], f"{v['title'][:60]} [{v['app']}]") 462 | 463 | 464 | def _datetime_arg(s: str) -> datetime: 465 | return datetime.strptime(s, "%Y-%m-%d") 466 | 467 | 468 | def _build_argparse(parser): 469 | parser.add_argument("--start", type=_datetime_arg) 470 | parser.add_argument("--end", type=_datetime_arg) 471 | 472 | subparsers = parser.add_subparsers(dest="cmd2") 473 | subparsers.add_parser("summary") 474 | subparsers.add_parser("summary_plot").add_argument("--save") 475 | subparsers.add_parser("apps") 476 | subparsers.add_parser("cat").add_argument("category") 477 | 478 | cat_plot = subparsers.add_parser("cat_plot") 479 | cat_plot.add_argument("--save") 480 | cat_plot.add_argument("category", nargs="+") 481 | 482 | return parser 483 | 484 | 485 | def pprint_secs_hhmmss(seconds): 486 | hours, remainder = divmod(seconds, 3600) 487 | minutes, seconds = divmod(remainder, 60) 488 | return ( 489 | (f"{int(hours)}h".rjust(4) if hours else "").ljust(5) 490 | + (f"{int(minutes)}m".rjust(3) if minutes else "").ljust(4) 491 | + (f"{int(seconds)}s".rjust(3)) 492 | ) 493 | 494 | 495 | def _print_summary(events): 496 | start = min(e.timestamp for e in events) 497 | end = max(e.timestamp + e.duration for e in events) 498 | duration = sum((e.duration for e in events), timedelta(0)) 499 | coverage = duration / (end - start) 500 | print(f" Start: {start}") 501 | print(f" End: {end}") 502 | print(f" Span: {end - start}") 503 | print(f" Duration: {duration}") 504 | print(f" Coverage: {round(100 * coverage)}%") 505 | print() 506 | 507 | 508 | day_offset = timedelta(hours=4) 509 | 510 | 511 | def _plot_category_daily_trend(events, categories): 512 | for cat in categories: 513 | events_cat = [e for e in events if cat in e.data["$category_hierarchy"]] 514 | ts = pd.Series( 515 | [e.duration.total_seconds() / 3600 for e in events_cat], 516 | index=pd.DatetimeIndex([e.timestamp for e in events_cat]).tz_convert("UTC"), 517 | ) 518 | ts = ts.resample("1D").apply("sum") 519 | ax = ts.plot(label=cat, legend=True) 520 | ax = ( 521 | ts.rolling(7, min_periods=2) 522 | .mean() 523 | .plot(label=f"{cat} 7d rolling", legend=True) 524 | ) 525 | ax = ( 526 | ts.rolling(30, min_periods=2) 527 | .mean() 528 | .plot(label=f"{cat} 30d rolling", legend=True) 529 | ) 530 | ax.set_ylabel("Hours") 531 | plt.xticks(rotation="vertical") 532 | plt.ylim(0) 533 | 534 | 535 | def _main(args): 536 | _init_classes("categories.toml") 537 | 538 | if args.cmd2 in ["summary", "summary_plot", "apps", "cat", "cat_plot"]: 539 | if not args.end: 540 | args.end = datetime.now() 541 | if not args.start: 542 | how_far_back = timedelta(hours=1 * 12) 543 | args.start = args.end - how_far_back 544 | events = get_events( 545 | args.start, 546 | args.end, 547 | include_toggl="./data/private/Toggl_time_entries_2017-12-17_to_2018-11-11.csv", 548 | ) 549 | _print_summary(events) 550 | 551 | events = classify(events, include_app=False) 552 | # pprint([e.data["$tags"] for e in classify(events)]) 553 | if args.cmd2 in ["summary", "apps"]: 554 | print(f"Total time: {sum((e.duration for e in events), timedelta(0))}") 555 | if args.cmd2 == "summary": 556 | time_per = time_per_category(events) 557 | elif args.cmd2 == "apps": 558 | time_per = time_per_app(events) 559 | for c, s in time_per.most_common(): 560 | print(pprint_secs_hhmmss(s) + f" {c}") 561 | elif args.cmd2 == "cat": 562 | _print_category(events, args.category, 30) 563 | elif args.cmd2 == "cat_plot": 564 | _plot_category_daily_trend(events, args.category) 565 | if args.save: 566 | plt.savefig(args.save, bbox_inches="tight") 567 | else: 568 | plt.show() 569 | elif args.cmd2 == "summary_plot": 570 | _plot_category_hierarchy_sunburst(events) 571 | if args.save: 572 | plt.savefig(args.save, bbox_inches="tight") 573 | else: 574 | plt.show() 575 | else: 576 | print(f"unknown subcommand to classify: {args.cmd2}") 577 | 578 | 579 | if __name__ == "__main__": 580 | _parser = argparse.ArgumentParser(description="") 581 | _parser = _build_argparse(_parser) 582 | _main(_parser.parse_args()) 583 | -------------------------------------------------------------------------------- /aw_research/filters.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List 3 | 4 | from aw_core.models import Event 5 | from aw_client import ActivityWatchClient 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def filter_short(events, threshold: float = 1): 12 | # TODO: Try to fill hole in timeline where events have been removed 13 | # (if events before and after where are the same) 14 | # Useful for filtering AFK data and to make data look "smoother". 15 | # Might be something for another function 16 | return [e for e in events if e.duration.total_seconds() > threshold] 17 | 18 | 19 | def filter_datafields(events: List[Event], fields: List[str]): 20 | """Filters away specific datafield from every event in a list""" 21 | for e in events: 22 | for field in fields: 23 | if field in e.data: 24 | e.data.pop(field) 25 | return events 26 | 27 | 28 | # TODO: Generalize 29 | BUCKET_AFK = "aw-watcher-afk_erb-main2-arch" 30 | BUCKET_WINDOW = "aw-watcher-window_erb-main2-arch" 31 | BUCKET_WEB = "aw-watcher-web-firefox" 32 | 33 | 34 | def test_filter_data() -> None: 35 | awapi = ActivityWatchClient("cleaner", testing=True) 36 | events = awapi.get_events(BUCKET_WEB, limit=-1) 37 | events = filter_datafields(events, ["title"]) 38 | assert "title" not in events[0].data 39 | 40 | 41 | def test_filter_short(): 42 | # TODO: This was used in dev and does not work. 43 | awapi = ActivityWatchClient("cleaner", testing=True) 44 | events = awapi.get_events(BUCKET_WEB, limit=-1) 45 | filter_short(events, threshold=1) 46 | 47 | events = awapi.get_events(BUCKET_WINDOW, limit=-1) 48 | filter_short(events, threshold=1) 49 | 50 | events = awapi.get_events(BUCKET_AFK, limit=-1) 51 | filter_short(events, threshold=30) 52 | 53 | 54 | if __name__ == "__main__": 55 | test_filter_data() 56 | test_filter_short() 57 | -------------------------------------------------------------------------------- /aw_research/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import argparse 3 | from typing import List, Pattern 4 | from pprint import pprint 5 | from collections import defaultdict 6 | from datetime import timedelta 7 | 8 | from aw_transform import heartbeat_reduce 9 | from aw_transform.flood import flood 10 | from aw_transform.simplify import simplify_string 11 | 12 | from aw_client import ActivityWatchClient 13 | 14 | from aw_research.redact import redact_words 15 | from aw_research.algorithmia import run_sentiment, run_LDA 16 | from aw_research.merge import merge_close_and_similar 17 | from aw_research.classify import _main as _main_classify 18 | from aw_research.classify import _build_argparse as _build_argparse_classify 19 | 20 | logging.basicConfig(level=logging.INFO) 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | def assert_no_overlap(events): 25 | overlap = False 26 | events = sorted(events, key=lambda e: e.timestamp) 27 | for e1, e2 in zip(events[:-1], events[1:]): 28 | e1_end = e1.timestamp + e1.duration 29 | gap = e2.timestamp - e1_end 30 | if gap < timedelta(0): 31 | logger.warning("Events overlapped: {}".format(gap)) 32 | overlap = True 33 | assert not overlap 34 | 35 | 36 | def _get_window_events(n=1000): 37 | client = ActivityWatchClient("aw-analyser", testing=True) 38 | buckets = client.get_buckets() 39 | 40 | bucket_id = None 41 | for _bid in buckets.keys(): 42 | if "window" in _bid and "testing" not in _bid: 43 | bucket_id = _bid 44 | 45 | if bucket_id: 46 | return client.get_events(bucket_id, limit=n) 47 | else: 48 | print("Did not find bucket") 49 | return [] 50 | 51 | 52 | def _main_redact(pattern: str, ignore_case: bool): 53 | logger.info("Retrieving events...") 54 | events = _get_window_events() 55 | 56 | logger.info("Redacting using regular expression: " + pattern) 57 | events = redact_words(events, pattern, ignore_case=ignore_case) 58 | 59 | print("NOTE: Redactions are not persisted to server") 60 | 61 | 62 | def _main_analyse(): 63 | logger.info("Retrieving events...") 64 | events = _get_window_events() 65 | 66 | logger.info("Running analysis...") 67 | titles = list({e.data["title"] for e in events}) 68 | out = run_LDA(titles) 69 | pprint(out.result) 70 | 71 | out = run_sentiment(titles) 72 | pprint([r for r in out.result if r["sentiment"] != 0]) 73 | 74 | out = run_sentiment(" ".join(titles)) 75 | pprint([r for r in out.result if r["sentiment"] != 0]) 76 | 77 | 78 | def _main_merge(): 79 | logger.info("Retrieving events...") 80 | events = _get_window_events(n=1000) 81 | events = simplify_string(events) 82 | 83 | merged_events = merge_close_and_similar(events) 84 | print( 85 | "{} events became {} after merging of similar ones".format( 86 | len(events), len(merged_events) 87 | ) 88 | ) 89 | 90 | # Debugging 91 | assert_no_overlap(events) 92 | assert_no_overlap(merged_events) 93 | print_most_common_titles(events) 94 | print_most_common_titles(merged_events) 95 | 96 | 97 | def _main_heartbeat_reduce(): 98 | logger.info("Retrieving events...") 99 | events = _get_window_events() 100 | events = simplify_string(events) 101 | 102 | logger.info("Beating hearts together...") 103 | merged_events = heartbeat_reduce(events, pulsetime=10) 104 | 105 | # Debugging 106 | assert_no_overlap(events) 107 | assert_no_overlap(merged_events) 108 | print_most_common_titles(events) 109 | print_most_common_titles(merged_events) 110 | 111 | 112 | def _main_flood(): 113 | logger.info("Retrieving events...") 114 | events = _get_window_events() 115 | events = simplify_string(events) 116 | 117 | logger.info("Flooding...") 118 | merged_events = flood(events) 119 | 120 | # Debugging 121 | assert_no_overlap(events) 122 | assert_no_overlap(merged_events) 123 | print_most_common_titles(events) 124 | print_most_common_titles(merged_events) 125 | 126 | 127 | def print_most_common_titles(events): 128 | counter = defaultdict(lambda: timedelta(0)) 129 | for e in events: 130 | counter[e.data["title"]] += e.duration 131 | 132 | print("-" * 30) 133 | 134 | def total_duration(events): 135 | return sum((e.duration for e in events), timedelta(0)) 136 | 137 | print("Total duration: {}".format(total_duration(events))) 138 | 139 | pairs = sorted(zip(counter.values(), counter.keys()), reverse=True) 140 | for duration, title in pairs[:15]: 141 | print("{:15s} - {}".format(str(duration), title)) 142 | 143 | print("-" * 30) 144 | 145 | 146 | def main(): 147 | parser = argparse.ArgumentParser() 148 | subparsers = parser.add_subparsers(dest="cmd") 149 | redact = subparsers.add_parser("redact") 150 | redact.add_argument( 151 | "pattern", 152 | help="Regular expression to match events with, a good example that matches on 3 words: \b(sensitive|secret|)\b", 153 | ) 154 | redact.add_argument( 155 | "--ignore-case", 156 | action="store_true", 157 | help="Ignore case sensitivity (the pattern and all strings are lowercased before matching)", 158 | ) 159 | subparsers.add_parser("analyse") 160 | subparsers.add_parser("merge") 161 | subparsers.add_parser("flood") 162 | subparsers.add_parser("heartbeat") 163 | classify = subparsers.add_parser("classify") 164 | _build_argparse_classify(classify) 165 | 166 | args = parser.parse_args() 167 | 168 | if args.cmd == "redact": 169 | _main_redact(args.pattern, args.ignore_case) 170 | elif args.cmd == "analyse": 171 | _main_analyse() 172 | elif args.cmd == "merge": 173 | _main_merge() 174 | elif args.cmd == "flood": 175 | _main_flood() 176 | elif args.cmd == "heartbeat": 177 | _main_heartbeat_reduce() 178 | elif args.cmd == "classify": 179 | _main_classify(args) 180 | else: 181 | parser.print_usage() 182 | -------------------------------------------------------------------------------- /aw_research/merge.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from copy import deepcopy 3 | from difflib import SequenceMatcher 4 | 5 | 6 | def similar(a, b): 7 | return SequenceMatcher(None, a, b).ratio() 8 | 9 | 10 | def merge_close_and_similar(events, pulsetime=10): 11 | """ 12 | Merges close window events with similar window title. 13 | 14 | Useful when a window is constantly making small changes 15 | to its window title that you don't care about. 16 | """ 17 | events = deepcopy(events) 18 | events = sorted(events, key=lambda e: e.timestamp) 19 | 20 | merged_events = [events[0]] 21 | 22 | for i in range(1, len(events)): 23 | e1 = merged_events[-1] 24 | e2 = events[i] 25 | 26 | merged = False 27 | 28 | if e1.data["app"] == e2.data["app"]: 29 | gap = e2.timestamp - (e1.timestamp + e1.duration) 30 | assert gap >= timedelta(0) 31 | 32 | # Only merge if events are close 33 | if gap <= timedelta(seconds=pulsetime): 34 | simscore = similar(e1.data["title"], e2.data["title"]) 35 | if simscore > 0.9: 36 | e1.duration = (e2.timestamp + e2.duration) - e1.timestamp 37 | merged = True 38 | 39 | if not merged: 40 | merged_events.append(e2) 41 | 42 | return merged_events 43 | -------------------------------------------------------------------------------- /aw_research/plot.py: -------------------------------------------------------------------------------- 1 | from random import randint, random 2 | from datetime import datetime, timezone, timedelta 3 | from typing import List 4 | 5 | import matplotlib.pyplot as plt 6 | from matplotlib.dates import date2num 7 | 8 | now = datetime.now(tz=timezone.utc) 9 | now = datetime(year=2017, month=7, day=7) 10 | 11 | 12 | def color_gen(): 13 | i = 0 14 | colors = ["r", "y", "g"] 15 | while True: 16 | yield colors[i % 3] 17 | i += 1 18 | 19 | 20 | def barchart(x: List[datetime], bar_sets: List[List[float]]): 21 | """ 22 | Based on: 23 | - https://stackoverflow.com/questions/17827748/matplotilb-bar-chart-diagonal-tick-labels 24 | - https://matplotlib.org/examples/pylab_examples/bar_stacked.html 25 | """ 26 | plt.figure() 27 | 28 | x = date2num(x) 29 | 30 | ax = plt.subplot(111) 31 | ax.set_xlabel("time") 32 | ax.set_ylabel("duration") 33 | ax.xaxis_date() 34 | # ax.set_xticklabels(["{}:{:02d}".format(dt.hour, dt.minute) for dt in x], rotation=45) 35 | 36 | n = len(bar_sets[0]) 37 | bottom = [0.0] * n 38 | colors = color_gen() 39 | for bars in bar_sets: 40 | color = next(colors) 41 | ax.bar( 42 | x, 43 | bars, 44 | width=1 / 24 / 1.5, 45 | align="center", 46 | color=color, 47 | bottom=bottom, 48 | label="a", 49 | ) 50 | bottom = [bottom[i] + bars[i] for i in range(n)] 51 | 52 | ax.legend() 53 | plt.show() 54 | 55 | 56 | if __name__ == "__main__": 57 | plt.style.use("ggplot") 58 | 59 | n = 50 60 | x = [now + timedelta(hours=i) for i in range(n)] 61 | y1 = [random() * 15 for i in range(n)] 62 | y2 = [random() * (30 - y1[i]) for i in range(n)] 63 | y3 = [random() * (60 - y1[i] - y2[i]) for i in range(n)] 64 | 65 | barchart(x, [y1, y2, y3]) 66 | -------------------------------------------------------------------------------- /aw_research/plot_sunburst.py: -------------------------------------------------------------------------------- 1 | """ 2 | From: https://stackoverflow.com/a/46790802/965332 3 | """ 4 | 5 | import numpy as np 6 | import matplotlib.pyplot as plt 7 | 8 | 9 | def sunburst(nodes, total=np.pi * 2, offset=0, level=0, ax=None): 10 | ax = ax or plt.subplot(111, projection="polar") 11 | 12 | if level == 0 and len(nodes) == 1: 13 | label, value, subnodes = nodes[0] 14 | ax.bar([0], [0.5], [np.pi * 2]) 15 | ax.text(0, 0, label, ha="center", va="center") 16 | sunburst(subnodes, total=value, level=level + 1, ax=ax) 17 | elif nodes: 18 | d = np.pi * 2 / total 19 | labels = [] 20 | widths = [] 21 | local_offset = offset 22 | for label, value, subnodes in nodes: 23 | labels.append(label) 24 | widths.append(value * d) 25 | sunburst(subnodes, total=total, offset=local_offset, level=level + 1, ax=ax) 26 | local_offset += value 27 | values = np.cumsum([offset * d] + widths[:-1]) 28 | heights = [1] * len(nodes) 29 | bottoms = np.zeros(len(nodes)) + level - 0.5 30 | rects = ax.bar( 31 | values, 32 | heights, 33 | widths, 34 | bottoms, 35 | linewidth=1, 36 | edgecolor="white", 37 | align="edge", 38 | ) 39 | for rect, label in zip(rects, labels): 40 | x = rect.get_x() + rect.get_width() / 2 41 | y = rect.get_y() + rect.get_height() / 2 42 | rotation = (90 + (360 - np.degrees(x) % 180)) % 360 43 | ax.text(x, y, label, rotation=rotation, ha="center", va="center") 44 | 45 | if level == 0: 46 | ax.set_theta_direction(-1) 47 | ax.set_theta_zero_location("N") 48 | ax.set_axis_off() 49 | -------------------------------------------------------------------------------- /aw_research/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ActivityWatch/aw-research/680f240cbe8443950109965097744ea7eed181c2/aw_research/py.typed -------------------------------------------------------------------------------- /aw_research/redact.py: -------------------------------------------------------------------------------- 1 | # TODO 2 | # Replace current redact method with: 3 | # 1. tag (classify.py) 4 | # 2. redact by tag 5 | 6 | import re 7 | import logging 8 | from typing import List, Callable, Tuple, Pattern, Any 9 | from pprint import pprint 10 | 11 | from aw_core.models import Event 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def _redact_full(event): 17 | for key in event.data: 18 | event.data[key] = "REDACTED" 19 | return event 20 | 21 | 22 | def _redact(events: List[Event], f: Callable[[str], bool]) -> Tuple[List[Event], int]: 23 | n = 0 24 | for i, event in enumerate(events): 25 | for key in event.data: 26 | if f(event.data[key]): 27 | n += 1 28 | logger.debug('Redacting: "{}"'.format(event.data[key])) 29 | events[i] = _redact_full(event) 30 | break 31 | return events, n 32 | 33 | 34 | def redact_words(events: List[Event], pattern: str, ignore_case=False): 35 | r = re.compile(pattern.lower() if ignore_case else pattern) 36 | events, n_redacted = _redact( 37 | events, lambda s: bool(r.search(s.lower() if ignore_case else s)) 38 | ) 39 | 40 | percent = round(100 * n_redacted / len(events), 2) 41 | logger.info( 42 | "# Redacted\n\tTotal: {}\n\tRedacted: {}\n\tPercent: {}%".format( 43 | len(events), n_redacted, percent 44 | ) 45 | ) 46 | 47 | return events 48 | -------------------------------------------------------------------------------- /aw_research/thankful.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pprint import pprint 3 | from collections import defaultdict 4 | from typing import List, Dict, Optional 5 | import logging 6 | 7 | import aw_client 8 | 9 | # pip install google-api-python-client 10 | import apiclient 11 | 12 | from joblib import Memory 13 | 14 | location = "./.cache/thankful" 15 | memory = Memory(location, verbose=0) 16 | 17 | logger = logging.getLogger(__name__) 18 | logging.getLogger("urllib3").setLevel(logging.WARNING) 19 | logging.getLogger("googleapiclient").setLevel(logging.WARNING) 20 | 21 | re_video_id = re.compile(r"watch\?v=[a-zA-Z0-9\-_]+") 22 | re_patreon_id = re.compile(r"patreon.com/[a-zA-Z0-9\-_]+") 23 | re_bitcoin_addr = re.compile(r"[13][a-km-zA-HJ-NP-Z1-9]{25,34}") 24 | re_eth_addr = re.compile(r"0x[A-Fa-f0-9]{40}") 25 | re_email_addr = re.compile(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+") 26 | 27 | YOUTUBE_API_SERVICE_NAME = "youtube" 28 | YOUTUBE_API_VERSION = "v3" 29 | YOUTUBE_DEVELOPER_KEY = ( 30 | "AIzaSyDSB0CRo8l4cLhxZtOSWEGuAUXfUMUBEV" + "Y" 31 | ) # slight obfuscation to prevent automated mining 32 | youtube = apiclient.discovery.build( 33 | YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION, developerKey=YOUTUBE_DEVELOPER_KEY 34 | ) 35 | 36 | 37 | class PaymentMethod: 38 | def __init__(self): 39 | self.url = None 40 | 41 | 42 | def find_patreon_link(text: str) -> str: 43 | # Find Patreon links, they are usually in the Links part of the profile but are sometimes present in user and video descriptions 44 | # (which is unavailable through the API: https://stackoverflow.com/a/33027866/965332) 45 | found = re_patreon_id.findall(text) 46 | if len(found) > 1: 47 | logger.warning("Found more than one patreon address") 48 | return found[0] if found else None 49 | 50 | 51 | def find_bitcoin_address(text: str) -> Optional[str]: 52 | # https://stackoverflow.com/questions/21683680/regex-to-match-bitcoin-addresses 53 | found = re_bitcoin_addr.findall(text) 54 | if len(found) > 1: 55 | logger.warning("Found more than one bitcoin address") 56 | return found[0] if found else None 57 | 58 | 59 | def find_eth_address(text: str) -> Optional[str]: 60 | found = re_eth_addr.findall(text) 61 | if len(found) > 1: 62 | logger.warning("Found more than one ethereum address") 63 | return found[0] if found else None 64 | 65 | 66 | def find_email_address(text: str) -> Optional[str]: 67 | found = re_email_addr.findall(text) 68 | if len(found) > 1: 69 | logger.warning("Found more than one email address") 70 | return found[0] if found else None 71 | 72 | 73 | assert find_patreon_link("patreon.com/3blue1brown") 74 | assert find_eth_address("0xbD2940e549C38Cc6b201767a0238c2C07820Ef35") 75 | assert find_email_address("erik@bjareho.lt") 76 | 77 | 78 | class Creator: 79 | """Creators are currently bound to platforms since cross-platform 80 | identity is still a non-trivial problem""" 81 | 82 | def __init__(self, service=None, identifier=None): 83 | self.service = service 84 | self.id = identifier 85 | self.title = None 86 | self.description = None 87 | 88 | # This should be a database query in the future 89 | self.creations = list() 90 | 91 | self.payment_methods = {} 92 | 93 | def __repr__(self): 94 | return "".format( 95 | self.id, self.title, str(list(self.payment_methods.keys())) 96 | ) 97 | 98 | def add_youtube_data(self): 99 | """This might not belon here when class is made more general""" 100 | # API Explorer: https://developers.google.com/apis-explorer/#p/youtube/v3/youtube.channels.list 101 | response = youtube.channels().list(id=self.id, part="snippet").execute() 102 | if response["items"]: 103 | creator_data = response["items"][0] 104 | self.title = creator_data["snippet"]["title"] 105 | self.description = creator_data["snippet"]["description"] 106 | 107 | def find_payment_methods(self): 108 | if self.description: 109 | self._find_patreon(self.description) 110 | self._find_bitcoin(self.description) 111 | self._find_eth(self.description) 112 | self._find_email(self.description) 113 | else: 114 | print("No channel description") 115 | 116 | for c in self.creations: 117 | if "patreon" not in self.payment_methods: 118 | self._find_patreon(c.description) 119 | if "bitcoin" not in self.payment_methods: 120 | self._find_bitcoin(c.description) 121 | if "eth" not in self.payment_methods: 122 | self._find_eth(c.description) 123 | if "email" not in self.payment_methods: 124 | self._find_email(c.description) 125 | 126 | def register_creation(self, creation: "Content"): 127 | self.creations.append(creation) 128 | 129 | def _find_patreon(self, text: str): 130 | patreon_link = find_patreon_link(text) 131 | if patreon_link: 132 | self.payment_methods["patreon"] = patreon_link 133 | 134 | def _find_bitcoin(self, text: str): 135 | bitcoin_addr = find_bitcoin_address(text) 136 | if bitcoin_addr: 137 | self.payment_methods["bitcoin"] = bitcoin_addr 138 | 139 | def _find_eth(self, text: str): 140 | eth_addr = find_eth_address(text) 141 | if eth_addr: 142 | self.payment_methods["eth"] = eth_addr 143 | 144 | def _find_email(self, text: str): 145 | addr = find_email_address(text) 146 | if addr: 147 | self.payment_methods["email"] = addr 148 | 149 | 150 | class Content: 151 | def __init__(self, identifier=None, title=None): 152 | self.id = identifier 153 | self.title = title 154 | self.description = None 155 | 156 | self.duration = 0 157 | 158 | # Misc data 159 | self.data = {} 160 | 161 | def __repr__(self): 162 | return "".format( 163 | self.id, self.title, self.duration 164 | ) 165 | 166 | def add_youtube_data(self): 167 | """This might not belong here when class is made more general""" 168 | # API Explorer: https://developers.google.com/apis-explorer/#p/youtube/v3/youtube.videos.list 169 | # Code example: https://github.com/youtube/api-samples/blob/master/python/search.py 170 | try: 171 | response = youtube.videos().list(id=self.id, part="snippet").execute() 172 | if response["items"]: 173 | video_data = response["items"][0] 174 | self.id = video_data["id"] 175 | self.title = video_data["snippet"]["title"] 176 | self.description = video_data["snippet"]["description"] 177 | self.data["channelId"] = video_data["snippet"]["channelId"] 178 | except apiclient.errors.HttpError as e: 179 | print(e.content) 180 | 181 | @property 182 | def url(self) -> Optional[str]: 183 | return "https://youtube.com/watch?v=" + self.id if self.id else None 184 | 185 | @property 186 | def uri(self): 187 | """ 188 | Idea for using uri's to identify content. Not sure if a good idea or not. Premature at least. 189 | 190 | On the format: :: 191 | Examples: 192 | - spotify:track:5avVpUakfMHD6qGpaH26CF (this is valid in the Spotify API) 193 | - youtube:video:dvzpvXLbpv4 194 | """ 195 | return self.service + ":" + self.type + ":" + self.id 196 | 197 | 198 | def find_youtube_content(events) -> List[Content]: 199 | """Finds YouTube content in events""" 200 | videos = defaultdict(Content) # type: Dict[str, Content] 201 | 202 | for event in events: 203 | if "youtube.com/watch?v=" in event.data["url"]: 204 | found = re_video_id.findall(event.data["url"]) 205 | if found: 206 | video_id = found[0][8:] 207 | videos[video_id].duration += event.duration.total_seconds() 208 | 209 | for id_, video in videos.items(): 210 | video.id = id_ 211 | 212 | return list(videos.values()) 213 | 214 | 215 | def get_channels_from_videos(videos: List[Content]): 216 | """Finds channels from a set of videos""" 217 | channels = defaultdict( 218 | lambda: Creator(service="youtube") 219 | ) # type: Dict[str, Creator] 220 | channel_id_set = { 221 | video.data["channelId"] for video in videos if "channelId" in video.data 222 | } 223 | 224 | for channel_id in channel_id_set: 225 | channel = channels[channel_id] 226 | channel.id = channel_id 227 | channel.add_youtube_data() 228 | 229 | return list(channels.values()) 230 | 231 | 232 | def assign_videos_to_channels(videos, channels): 233 | channels = {channel.id: channel for channel in channels} 234 | 235 | for video in videos: 236 | if "channelId" in video.data: 237 | channel = channels[video.data["channelId"]] 238 | channel.register_creation(video) 239 | 240 | 241 | @memory.cache() 242 | def get_yt_videos(): 243 | awapi = aw_client.ActivityWatchClient("thankful-test", testing=True) 244 | web_events = awapi.get_events(bucket_id="aw-watcher-web-chrome", limit=-1) 245 | 246 | yt_videos = find_youtube_content(web_events) 247 | for video in yt_videos: 248 | video.add_youtube_data() 249 | return yt_videos 250 | 251 | 252 | @memory.cache() 253 | def get_channels(yt_videos): 254 | channels = get_channels_from_videos(yt_videos) 255 | assign_videos_to_channels(yt_videos, channels) 256 | for channel in channels: 257 | channel.find_payment_methods() 258 | return channels 259 | 260 | 261 | def _main(): 262 | logging.basicConfig(level=logging.DEBUG) 263 | 264 | channels = get_channels(get_yt_videos()) 265 | 266 | # for channel in channels: 267 | # if channel.payment_methods: 268 | # print(channel) 269 | 270 | for c in channels: 271 | if ( 272 | c.payment_methods 273 | or c.description 274 | and re.findall("(BTC|[Bb]itcoin)|(ETH|[Ee]ther(eum)?)", c.description) 275 | ): 276 | print("-" * 80) 277 | print(c) 278 | print(c.description) 279 | 280 | n_with_payment_methods = len(list(filter(lambda c: c.payment_methods, channels))) 281 | print( 282 | "Number of found channels with payment methods: {} out of {}".format( 283 | n_with_payment_methods, len(channels) 284 | ) 285 | ) 286 | 287 | for method in ["eth", "bitcoin", "patreon", "email"]: 288 | n_with_method = len([c for c in channels if method in c.payment_methods]) 289 | print(" - {}: {} out of {}".format(method, n_with_method, len(channels))) 290 | 291 | # pprint(channels) 292 | 293 | 294 | if __name__ == "__main__": 295 | _main() 296 | -------------------------------------------------------------------------------- /aw_research/tree.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Union, List 3 | 4 | 5 | class Node: 6 | """ 7 | Used to represent a tree with a value (usually time) 8 | 9 | Useful to visualize time spent in each category. 10 | """ 11 | def __init__(self, label: str, value: int): 12 | self.label = label 13 | self.value = value 14 | self.children = [] 15 | 16 | def __repr__(self) -> str: 17 | return f"" 18 | 19 | def __contains__(self, label: Union[str, List[str]]) -> bool: 20 | if isinstance(label, list): 21 | node = self 22 | for sublabel in label: 23 | node = node[sublabel] 24 | return node 25 | else: 26 | return any(label == child.label for child in self.children) 27 | 28 | def __getitem__(self, label: str) -> 'Node': 29 | return next(child for child in self.children if child.label == label) 30 | 31 | def __iadd__(self, other: 'Node') -> 'Node': 32 | assert isinstance(other, Node) 33 | self.children.append(other) 34 | return self 35 | 36 | def total(self) -> Union[int, timedelta]: 37 | acc = self.value 38 | if isinstance(self.value, timedelta): 39 | zero = timedelta() 40 | else: 41 | zero = 0 42 | acc += sum([c.total() for c in self.children], zero) 43 | return acc 44 | 45 | def print(self, depth=1, width=24, indent=4, sort=True) -> str: 46 | total = self.total() 47 | children = self.children 48 | if sort: 49 | children = sorted(children, key=lambda c: c.total(), reverse=True) 50 | label = f"{self.label}:".ljust(width - indent * depth) 51 | parent = f" {total} {'(' + str(self.value) + ')' if self.value != total else ''}\n" 52 | children = "".join([(" " * indent * depth) + node.print(depth=depth + 1) for node in children]) 53 | return label + parent + children 54 | 55 | 56 | def test_node(): 57 | root = Node('root', 1) 58 | 59 | work = Node('Work', 2) 60 | root += work 61 | assert 'Work' in root 62 | 63 | prog = Node('Programming', 2) 64 | work += prog 65 | assert 'Programming' in work 66 | assert work['Programming'] 67 | 68 | media = Node('Media', 3) 69 | root += media 70 | media += Node('YouTube', 5) 71 | 72 | print(work.print()) 73 | print(root.print()) 74 | assert root.total() == 13 75 | 76 | 77 | def test_node_timedelta(): 78 | root = Node('root', timedelta(seconds=5)) 79 | root += Node('work', timedelta(seconds=30)) 80 | print(root.total()) 81 | print(root.print(sort=False)) 82 | 83 | 84 | if __name__ == "__main__": 85 | test_node() 86 | test_node_timedelta() 87 | -------------------------------------------------------------------------------- /aw_research/util.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import ( 3 | datetime, 4 | time, 5 | timedelta, 6 | timezone, 7 | ) 8 | from typing import List, Tuple 9 | 10 | import pandas as pd 11 | from aw_core import Event 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def split_event_on_time(event: Event, timestamp: datetime) -> Tuple[Event, Event]: 17 | event1 = Event(**event) 18 | event2 = Event(**event) 19 | assert timestamp > event.timestamp 20 | event1.duration = timestamp - event1.timestamp 21 | event2.duration = (event2.timestamp + event2.duration) - timestamp 22 | event2.timestamp = timestamp 23 | assert event1.timestamp < event2.timestamp 24 | assert event.duration == event1.duration + event2.duration 25 | return event1, event2 26 | 27 | 28 | def next_hour(timestamp: datetime) -> datetime: 29 | return datetime.combine(timestamp.date(), time(timestamp.hour)).replace( 30 | tzinfo=timestamp.tzinfo 31 | ) + timedelta(hours=1) 32 | 33 | 34 | def test_next_hour() -> None: 35 | assert next_hour(datetime(2019, 1, 1, 6, 23)) == datetime(2019, 1, 1, 7) 36 | assert next_hour(datetime(2019, 1, 1, 23, 23)) == datetime(2019, 1, 2, 0) 37 | 38 | 39 | def split_event_on_hour(event: Event) -> List[Event]: 40 | hours_crossed = (event.timestamp + event.duration).hour - event.timestamp.hour 41 | if hours_crossed == 0: 42 | return [event] 43 | else: 44 | _next_hour = next_hour(event.timestamp) 45 | event1, event_n = split_event_on_time(event, _next_hour) 46 | return [event1, *split_event_on_hour(event_n)] 47 | 48 | 49 | def test_split_event_on_hour() -> None: 50 | e = Event( 51 | timestamp=datetime(2019, 1, 1, 11, 30, tzinfo=timezone.utc), 52 | duration=timedelta(minutes=1), 53 | ) 54 | assert len(split_event_on_hour(e)) == 1 55 | 56 | e = Event( 57 | timestamp=datetime(2019, 1, 1, 11, 30, tzinfo=timezone.utc), 58 | duration=timedelta(hours=2), 59 | ) 60 | split_events = split_event_on_hour(e) 61 | assert len(split_events) == 3 62 | 63 | 64 | def start_of_day(dt: datetime) -> datetime: 65 | today = dt.date() 66 | return datetime(today.year, today.month, today.day, tzinfo=timezone.utc) 67 | 68 | 69 | def end_of_day(dt: datetime) -> datetime: 70 | return start_of_day(dt) + timedelta(days=1) 71 | 72 | 73 | def get_week_start(dt: datetime) -> datetime: 74 | start = dt - timedelta(days=dt.date().weekday()) 75 | return datetime.combine(start.date(), time(), tzinfo=dt.tzinfo) 76 | 77 | 78 | def is_in_same_week(dt1: datetime, dt2: datetime) -> bool: 79 | return get_week_start(dt1) == get_week_start(dt2) 80 | 81 | 82 | def split_into_weeks(start: datetime, end: datetime) -> List[Tuple[datetime, datetime]]: 83 | if start == end: 84 | return [] 85 | elif is_in_same_week(start, end): 86 | return [(start, end)] 87 | else: 88 | split = get_week_start(start) + timedelta(days=7) 89 | return [(start, split)] + split_into_weeks(split, end) 90 | 91 | 92 | def test_split_into_weeks() -> None: 93 | # tznaive 94 | split = split_into_weeks( 95 | datetime(2019, 1, 3, 12), 96 | datetime(2019, 1, 18, 0, 2), 97 | ) 98 | for dtstart, dtend in split: 99 | print(dtstart, dtend) 100 | assert len(split) == 3 101 | 102 | # tzaware 103 | split = split_into_weeks( 104 | datetime(2019, 1, 3, 12, tzinfo=timezone.utc), 105 | datetime(2019, 1, 18, 0, 2, tzinfo=timezone.utc), 106 | ) 107 | for dtstart, dtend in split: 108 | print(dtstart, dtend) 109 | assert len(split) == 3 110 | 111 | 112 | def split_into_days(start: datetime, end: datetime) -> List[Tuple[datetime, datetime]]: 113 | if start == end: 114 | return [] 115 | elif start.date() == end.date(): 116 | return [(start, end)] 117 | else: 118 | split = datetime.combine(start.date(), time()) + timedelta(days=1) 119 | return [(start, split)] + split_into_days(split, end) 120 | 121 | 122 | def test_split_into_days() -> None: 123 | split = split_into_days(datetime(2019, 1, 3, 12), datetime(2019, 1, 6, 0, 2)) 124 | for dtstart, dtend in split: 125 | print(dtstart, dtend) 126 | assert len(split) == 4 127 | 128 | 129 | def verify_no_overlap(events: List[Event]) -> None: 130 | events = sorted(events, key=lambda e: e.timestamp) 131 | try: 132 | assert all( 133 | [ 134 | e1.timestamp + e1.duration <= e2.timestamp 135 | for e1, e2 in zip(events[:-1], events[1:]) 136 | ] 137 | ) 138 | except AssertionError: 139 | n_overlaps, total_overlap = compute_total_overlap(events) 140 | print( 141 | f"[WARNING] Found {n_overlaps} events overlapping, totalling: {total_overlap}" 142 | ) 143 | 144 | 145 | def compute_total_overlap(events: List[Event]) -> Tuple[int, timedelta]: 146 | events = sorted(events, key=lambda e: e.timestamp) 147 | n_overlaps = 0 148 | total_overlap = timedelta() 149 | i, j = 0, 1 150 | assert len(events) > 1 151 | while j < len(events): 152 | e1, e2 = events[i], events[j] 153 | if e1.timestamp + e1.duration > e2.timestamp: 154 | n_overlaps += 1 155 | overlap_start = max(e1.timestamp, e2.timestamp) 156 | overlap_end = min(e1.timestamp + e1.duration, e2.timestamp + e2.duration) 157 | total_overlap += overlap_end - overlap_start 158 | j += 1 159 | logger.debug("j+") 160 | else: 161 | if j - i > 1: 162 | # if j isn't directly ahead of i, we can skip ahead 163 | i += 1 164 | logger.debug("i+") 165 | else: 166 | # if j is directly ahead of i, we can step both forward 167 | i += 1 168 | j += 1 169 | logger.debug("i=j+") 170 | return n_overlaps, total_overlap 171 | 172 | 173 | def test_compute_total_overlap() -> None: 174 | # Simple test 175 | events = [ 176 | Event( 177 | timestamp=datetime(2019, 1, 1, 12, tzinfo=timezone.utc), 178 | duration=timedelta(hours=1), 179 | ), 180 | Event( 181 | timestamp=datetime(2019, 1, 1, 12, 30, tzinfo=timezone.utc), 182 | duration=timedelta(hours=1), 183 | ), 184 | ] 185 | assert compute_total_overlap(events) == (1, timedelta(minutes=30)) 186 | 187 | # Test with multiple overlaps in sequence after long event 188 | events = [ 189 | Event( 190 | timestamp=datetime(2019, 1, 1, 12, tzinfo=timezone.utc), 191 | duration=timedelta(hours=2), 192 | ), 193 | Event( 194 | timestamp=datetime(2019, 1, 1, 12, 30, tzinfo=timezone.utc), 195 | duration=timedelta(hours=1), 196 | ), 197 | Event( 198 | timestamp=datetime(2019, 1, 1, 13, 30, tzinfo=timezone.utc), 199 | duration=timedelta(hours=1), 200 | ), 201 | ] 202 | assert compute_total_overlap(events) == (2, timedelta(minutes=90)) 203 | 204 | # Test with multiple overlaps in sequence after long event, with inter-overlap overlap 205 | events = [ 206 | Event( 207 | timestamp=datetime(2019, 1, 1, 12, tzinfo=timezone.utc), 208 | duration=timedelta(hours=2), 209 | ), 210 | Event( 211 | timestamp=datetime(2019, 1, 1, 12, 30, tzinfo=timezone.utc), 212 | duration=timedelta(hours=1), 213 | ), 214 | Event( 215 | timestamp=datetime(2019, 1, 1, 13, 15, tzinfo=timezone.utc), 216 | duration=timedelta(minutes=15), 217 | ), 218 | ] 219 | assert compute_total_overlap(events) == (2, timedelta(minutes=75)) 220 | 221 | 222 | # TODO: Write test that ensures timezone localization is handled correctly 223 | def categorytime_per_day(events, category): 224 | events = [e for e in events if category in e.data["$category_hierarchy"]] 225 | if not events: 226 | raise Exception("No events to calculate on") 227 | ts = pd.Series( 228 | [e.duration.total_seconds() / 3600 for e in events], 229 | index=pd.DatetimeIndex([e.timestamp for e in events]).tz_localize(None), 230 | ) 231 | return ts.resample("1D").apply("sum") 232 | 233 | 234 | # TODO: Refactor into categorytime_per_hour? (that you just pass a day of events to) 235 | def categorytime_during_day( 236 | events: List[Event], category: str, day: datetime 237 | ) -> pd.Series: 238 | events = [e for e in events if category in e.data["$category_hierarchy"]] 239 | events = [e for e in events if e.timestamp > day] 240 | _events = [] 241 | for e in events: 242 | _events.extend(split_event_on_hour(e)) 243 | events = _events 244 | ts = pd.Series( 245 | [e.duration.total_seconds() / 3600 for e in events], 246 | index=pd.DatetimeIndex([e.timestamp for e in events]), 247 | ) 248 | return ts.resample("1H").apply("sum") 249 | -------------------------------------------------------------------------------- /categories.example.csv: -------------------------------------------------------------------------------- 1 | [Ss]potify|[Ss]oundcloud;Music 2 | [Yy]ouTube|[Vv]imeo|[Pp]lex|TED Talk;Video 3 | [Pp]rogramming|[Gg]it[Hh]ub|[Pp]ython|localhost|Pull Request|doc(s|umentation)|Stack Overflow;Programming 4 | Google (Sheets|Slides|Forms)|Analytics;Work 5 | Messenger|(messaged you)|Gmail|Calendar;Communication 6 | [Ff]acebook|[Rr]eddit|[Tt]witter;Social Media 7 | -------------------------------------------------------------------------------- /categories.example.toml: -------------------------------------------------------------------------------- 1 | [categories] 2 | Communication = 'Messenger|(messaged you)|Gmail|Calendar' 3 | 4 | [categories.Media] 5 | Music = "[Ss]potify|[Ss]oundcloud" 6 | Video = "[Yy]ouTube|[Vv]imeo|[Pp]lex|TED Talk" 7 | "Social Media" = "[Ff]acebook|[Rr]eddit|[Tt]witter" 8 | 9 | [categories.Work] 10 | $re = "Google (Sheets|Slides|Forms)|Analytics" 11 | Programming = "[Pp]rogramming|[Gg]it[Hh]ub|[Pp]ython|localhost|Pull Request|doc(s|umentation)|Stack Overflow" 12 | -------------------------------------------------------------------------------- /examples/afk_and_audible.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from aw_client import ActivityWatchClient 4 | 5 | from aw_transform import filter_period_intersect 6 | 7 | 8 | def _check_nonoverlapping(events): 9 | events = sorted(events, key=lambda e: e.timestamp) 10 | last_end = None 11 | for e in events: 12 | end = e.timestamp + e.duration 13 | if last_end: 14 | assert last_end <= end 15 | last_end = end 16 | 17 | 18 | def merge(events1, events2): 19 | result = ... 20 | _check_nonoverlapping(result) 21 | return result 22 | 23 | 24 | def all_active_webactivity(): 25 | """Returns activity during non-afk events or when tab is audible""" 26 | awapi = ActivityWatchClient("test", testing=True) 27 | 28 | start = datetime.now() - timedelta(days=7) 29 | tabevents = awapi.get_events("aw-watcher-web-chrome", start=start) 30 | afkevents = awapi.get_events("aw-watcher-afk_erb-laptop2-arch", start=start) 31 | 32 | afkevents_notafk = list(filter(lambda e: e.data["status"] == "not-afk", afkevents)) 33 | tabevents_audible = list(filter(lambda e: "audible" in e.data and e.data["audible"], tabevents)) 34 | 35 | # TODO: Implement merge 36 | # activeevents = merge(afkevents_notafk, tabevents_audible) 37 | # This isn't perfect, buggy when a notafk/audible events is contained by another 38 | activeevents = afkevents_notafk + tabevents_audible 39 | 40 | return filter_period_intersect(tabevents, activeevents) 41 | 42 | 43 | if __name__ == "__main__": 44 | from pprint import pprint 45 | 46 | pprint(all_active_webactivity()) 47 | -------------------------------------------------------------------------------- /examples/pandas_example.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pandas as pd 4 | 5 | from aw_client import ActivityWatchClient 6 | 7 | 8 | def get_events(bid): 9 | return ActivityWatchClient("test", testing=True).get_events(bid, start=datetime.now() - timedelta(days=7), limit=-1) 10 | 11 | 12 | def to_dataframe(events): 13 | return pd.DataFrame(dict(timestamp=e.timestamp, duration=e.duration, **e.data) for e in events).set_index('timestamp') 14 | 15 | 16 | if __name__ == "__main__": 17 | events = get_events("aw-watcher-window_erb-laptop2-arch") 18 | df = to_dataframe(events) 19 | print(df.tail(5)) 20 | print(df.groupby('app').sum().drop('title', axis=1).sort_values('duration', ascending=False)) 21 | 22 | print(df.groupby('app', as_index=True).resample('1D').agg({"duration": "sum"}).reset_index().set_index(['timestamp', 'app']).sort_index()) 23 | -------------------------------------------------------------------------------- /examples/plot_timeperiods.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, timedelta 2 | from typing import List, Tuple 3 | from io import StringIO 4 | 5 | import json 6 | import sys 7 | 8 | from matplotlib.dates import DateFormatter, SecondLocator 9 | import matplotlib 10 | import matplotlib.pyplot as plt 11 | import matplotlib.patches as mpatches 12 | 13 | import numpy as np 14 | 15 | from iso8601 import parse_date as convdt 16 | 17 | LoadDataTuple = Tuple["np.ndarray[datetime]", "np.ndarray[datetime]", "np.ndarray[str]"] 18 | 19 | 20 | def _construct_date_array(startdates: List[datetime]) -> "np.ndarray[str]": 21 | return np.array(list(map(lambda dt: dt.date().isoformat(), startdates))) 22 | 23 | 24 | def load_data(filepath: str) -> LoadDataTuple: 25 | with open(filepath) as f: 26 | data = json.load(f)[0] 27 | start = np.array([convdt(e['timestamp'].split(".")[0]) for e in data]) 28 | stop = np.array([convdt(e['timestamp']) + timedelta(seconds=e['duration']) for e in data]) 29 | state = np.array([e["data"]["app"] for e in data]) 30 | return start, stop, state 31 | 32 | 33 | def load_data_example() -> LoadDataTuple: 34 | # The example data 35 | a = StringIO(""" 36 | 2018-05-23T10:15:22 2018-05-23T10:38:30 Chrome 37 | 2018-05-23T11:15:23 2018-05-23T11:15:28 Alacritty 38 | 2018-05-24T10:16:00 2018-05-24T14:17:10 Chrome 39 | 2018-05-25T09:16:30 2018-05-25T14:36:50 Cemu 40 | 2018-05-27T08:19:30 2018-05-27T20:26:50 Chrome 41 | """) 42 | 43 | #Use numpy to read the data in. 44 | data = np.genfromtxt(a, converters={1: convdt, 2: convdt}, 45 | names=['start', 'stop', 'state'], dtype=None, encoding=None) 46 | return data['start'], data['stop'], data['state'] 47 | 48 | 49 | def same_date(dts: List[datetime]): 50 | return list(map(lambda dt: datetime.combine(date(1900, 1, 1), dt.time()), dts)) 51 | 52 | 53 | def plot(start: "np.ndarray[datetime]", stop: "np.ndarray[datetime]", state: "np.ndarray[str]", cap: "np.ndarray[str]"): 54 | """Originally based on: https://stackoverflow.com/a/7685336/965332""" 55 | # Get unique captions, their indices, and the inverse mapping 56 | captions, unique_idx, caption_inv = np.unique(cap, 1, 1) 57 | 58 | # Build y values from the number of unique captions 59 | y = (caption_inv + 1) / float(len(captions) + 1) 60 | 61 | # Build colors 62 | states, _, states_inv = np.unique(state, 1, 1) 63 | cmap = plt.get_cmap('tab10') 64 | colors = cmap(np.linspace(0, 1, len(states))) 65 | 66 | # Plot function 67 | def timelines(y, xstart, xstop, color): 68 | """Plot timelines at y from xstart to xstop with given color.""" 69 | plt.hlines(y, same_date(xstart), same_date(xstop), color, lw=12) 70 | 71 | timelines(y, start, stop, colors[states_inv]) 72 | 73 | # Setup the plot 74 | plt.title("Timeline") 75 | ax = plt.gca() 76 | 77 | # Create the legend 78 | plt.legend(handles=[mpatches.Patch(color=colors[i], label=s) for i, s in enumerate(states)]) 79 | 80 | # Setup the xaxis 81 | ax.xaxis_date() 82 | ax.xaxis.set_major_formatter(DateFormatter('%H:%M')) 83 | ax.xaxis.set_major_locator(SecondLocator(interval=60 * 60)) # used to be SecondLocator(0, interval=20) 84 | plt.xlabel('Time') 85 | plt.setp(plt.gca().get_xticklabels(), rotation=45, horizontalalignment='right') 86 | plt.xlim(datetime(1900, 1, 1, 0), datetime(1900, 1, 1, 23, 59)) 87 | 88 | # Setup the yaxis 89 | plt.ylabel('Date') 90 | plt.yticks(y[unique_idx], captions) 91 | plt.ylim(0, 1) 92 | 93 | plt.show() 94 | 95 | 96 | def _main(): 97 | fpath = sys.argv.pop() 98 | start, stop, state = load_data(fpath) 99 | cap = _construct_date_array(start) 100 | 101 | plot(start, stop, state, cap) 102 | 103 | 104 | if __name__ == "__main__": 105 | _main() 106 | -------------------------------------------------------------------------------- /parent_categories.csv.example: -------------------------------------------------------------------------------- 1 | Music;Entertainment 2 | Video;Entertainment 3 | Programming;Work 4 | -------------------------------------------------------------------------------- /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.4.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", "cloudpickle"] 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", "cloudpickle"] 29 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] 30 | 31 | [[package]] 32 | name = "aw-client" 33 | version = "0.5.9" 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.7,<0.6.0" 41 | click = ">=8.0,<9.0" 42 | persist-queue = "*" 43 | requests = "*" 44 | tabulate = "*" 45 | typing-extensions = "*" 46 | 47 | [[package]] 48 | name = "aw-core" 49 | version = "0.5.9" 50 | description = "Core library for ActivityWatch" 51 | category = "main" 52 | optional = false 53 | python-versions = ">=3.7,<4.0" 54 | 55 | [package.dependencies] 56 | appdirs = ">=1.4.4,<2.0.0" 57 | deprecation = "*" 58 | iso8601 = ">=1.0.2,<2.0.0" 59 | jsonschema = ">=4.3,<5.0" 60 | peewee = ">=3.0.0,<4.0.0" 61 | python-json-logger = "*" 62 | rfc3339-validator = ">=0.1.4,<0.2.0" 63 | strict-rfc3339 = ">=0.7,<0.8" 64 | TakeTheTime = ">=0.3.1,<0.4.0" 65 | timeslot = "*" 66 | tomlkit = "*" 67 | 68 | [package.extras] 69 | mongo = ["pymongo (>=3.10.0,<4.0.0)"] 70 | 71 | [[package]] 72 | name = "black" 73 | version = "22.1.0" 74 | description = "The uncompromising code formatter." 75 | category = "dev" 76 | optional = false 77 | python-versions = ">=3.6.2" 78 | 79 | [package.dependencies] 80 | click = ">=8.0.0" 81 | mypy-extensions = ">=0.4.3" 82 | pathspec = ">=0.9.0" 83 | platformdirs = ">=2" 84 | tomli = ">=1.1.0" 85 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 86 | 87 | [package.extras] 88 | colorama = ["colorama (>=0.4.3)"] 89 | d = ["aiohttp (>=3.7.4)"] 90 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 91 | uvloop = ["uvloop (>=0.15.2)"] 92 | 93 | [[package]] 94 | name = "cachetools" 95 | version = "5.0.0" 96 | description = "Extensible memoizing collections and decorators" 97 | category = "main" 98 | optional = false 99 | python-versions = "~=3.7" 100 | 101 | [[package]] 102 | name = "certifi" 103 | version = "2021.10.8" 104 | description = "Python package for providing Mozilla's CA Bundle." 105 | category = "main" 106 | optional = false 107 | python-versions = "*" 108 | 109 | [[package]] 110 | name = "charset-normalizer" 111 | version = "2.0.12" 112 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 113 | category = "main" 114 | optional = false 115 | python-versions = ">=3.5.0" 116 | 117 | [package.extras] 118 | unicode_backport = ["unicodedata2"] 119 | 120 | [[package]] 121 | name = "click" 122 | version = "8.0.4" 123 | description = "Composable command line interface toolkit" 124 | category = "main" 125 | optional = false 126 | python-versions = ">=3.6" 127 | 128 | [package.dependencies] 129 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 130 | 131 | [[package]] 132 | name = "colorama" 133 | version = "0.4.4" 134 | description = "Cross-platform colored terminal text." 135 | category = "main" 136 | optional = false 137 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 138 | 139 | [[package]] 140 | name = "coverage" 141 | version = "6.3.2" 142 | description = "Code coverage measurement for Python" 143 | category = "dev" 144 | optional = false 145 | python-versions = ">=3.7" 146 | 147 | [package.dependencies] 148 | tomli = {version = "*", optional = true, markers = "extra == \"toml\""} 149 | 150 | [package.extras] 151 | toml = ["tomli"] 152 | 153 | [[package]] 154 | name = "cycler" 155 | version = "0.11.0" 156 | description = "Composable style cycles" 157 | category = "main" 158 | optional = false 159 | python-versions = ">=3.6" 160 | 161 | [[package]] 162 | name = "deprecation" 163 | version = "2.1.0" 164 | description = "A library to handle automated deprecations" 165 | category = "main" 166 | optional = false 167 | python-versions = "*" 168 | 169 | [package.dependencies] 170 | packaging = "*" 171 | 172 | [[package]] 173 | name = "fonttools" 174 | version = "4.31.2" 175 | description = "Tools to manipulate font files" 176 | category = "main" 177 | optional = false 178 | python-versions = ">=3.7" 179 | 180 | [package.extras] 181 | all = ["fs (>=2.2.0,<3)", "lxml (>=4.0,<5)", "zopfli (>=0.1.4)", "lz4 (>=1.7.4.2)", "matplotlib", "sympy", "skia-pathops (>=0.5.0)", "brotlicffi (>=0.8.0)", "scipy", "brotli (>=1.0.1)", "munkres", "unicodedata2 (>=14.0.0)", "xattr"] 182 | graphite = ["lz4 (>=1.7.4.2)"] 183 | interpolatable = ["scipy", "munkres"] 184 | lxml = ["lxml (>=4.0,<5)"] 185 | pathops = ["skia-pathops (>=0.5.0)"] 186 | plot = ["matplotlib"] 187 | symfont = ["sympy"] 188 | type1 = ["xattr"] 189 | ufo = ["fs (>=2.2.0,<3)"] 190 | unicode = ["unicodedata2 (>=14.0.0)"] 191 | woff = ["zopfli (>=0.1.4)", "brotlicffi (>=0.8.0)", "brotli (>=1.0.1)"] 192 | 193 | [[package]] 194 | name = "google-api-core" 195 | version = "2.7.1" 196 | description = "Google API client core library" 197 | category = "main" 198 | optional = false 199 | python-versions = ">=3.6" 200 | 201 | [package.dependencies] 202 | google-auth = ">=1.25.0,<3.0dev" 203 | googleapis-common-protos = ">=1.52.0,<2.0dev" 204 | protobuf = ">=3.12.0" 205 | requests = ">=2.18.0,<3.0.0dev" 206 | 207 | [package.extras] 208 | grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio-status (>=1.33.2,<2.0dev)"] 209 | grpcgcp = ["grpcio-gcp (>=0.2.2)"] 210 | grpcio-gcp = ["grpcio-gcp (>=0.2.2)"] 211 | 212 | [[package]] 213 | name = "google-api-python-client" 214 | version = "2.42.0" 215 | description = "Google API Client Library for Python" 216 | category = "main" 217 | optional = false 218 | python-versions = ">=3.6" 219 | 220 | [package.dependencies] 221 | google-api-core = ">=1.31.5,<2.0.0 || >2.3.0,<3.0.0dev" 222 | google-auth = ">=1.16.0,<3.0.0dev" 223 | google-auth-httplib2 = ">=0.1.0" 224 | httplib2 = ">=0.15.0,<1dev" 225 | uritemplate = ">=3.0.1,<5" 226 | 227 | [[package]] 228 | name = "google-auth" 229 | version = "2.6.2" 230 | description = "Google Authentication Library" 231 | category = "main" 232 | optional = false 233 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" 234 | 235 | [package.dependencies] 236 | cachetools = ">=2.0.0,<6.0" 237 | pyasn1-modules = ">=0.2.1" 238 | rsa = {version = ">=3.1.4,<5", markers = "python_version >= \"3.6\""} 239 | six = ">=1.9.0" 240 | 241 | [package.extras] 242 | aiohttp = ["requests (>=2.20.0,<3.0.0dev)", "aiohttp (>=3.6.2,<4.0.0dev)"] 243 | pyopenssl = ["pyopenssl (>=20.0.0)"] 244 | reauth = ["pyu2f (>=0.1.5)"] 245 | 246 | [[package]] 247 | name = "google-auth-httplib2" 248 | version = "0.1.0" 249 | description = "Google Authentication Library: httplib2 transport" 250 | category = "main" 251 | optional = false 252 | python-versions = "*" 253 | 254 | [package.dependencies] 255 | google-auth = "*" 256 | httplib2 = ">=0.15.0" 257 | six = "*" 258 | 259 | [[package]] 260 | name = "googleapis-common-protos" 261 | version = "1.56.0" 262 | description = "Common protobufs used in Google APIs" 263 | category = "main" 264 | optional = false 265 | python-versions = ">=3.6" 266 | 267 | [package.dependencies] 268 | protobuf = ">=3.12.0" 269 | 270 | [package.extras] 271 | grpc = ["grpcio (>=1.0.0)"] 272 | 273 | [[package]] 274 | name = "httplib2" 275 | version = "0.20.4" 276 | description = "A comprehensive HTTP client library." 277 | category = "main" 278 | optional = false 279 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 280 | 281 | [package.dependencies] 282 | pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} 283 | 284 | [[package]] 285 | name = "idna" 286 | version = "3.3" 287 | description = "Internationalized Domain Names in Applications (IDNA)" 288 | category = "main" 289 | optional = false 290 | python-versions = ">=3.5" 291 | 292 | [[package]] 293 | name = "importlib-resources" 294 | version = "5.4.0" 295 | description = "Read resources from Python packages" 296 | category = "main" 297 | optional = false 298 | python-versions = ">=3.6" 299 | 300 | [package.dependencies] 301 | zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} 302 | 303 | [package.extras] 304 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 305 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"] 306 | 307 | [[package]] 308 | name = "iniconfig" 309 | version = "1.1.1" 310 | description = "iniconfig: brain-dead simple config-ini parsing" 311 | category = "dev" 312 | optional = false 313 | python-versions = "*" 314 | 315 | [[package]] 316 | name = "iso8601" 317 | version = "1.0.2" 318 | description = "Simple module to parse ISO 8601 dates" 319 | category = "main" 320 | optional = false 321 | python-versions = ">=3.6.2,<4.0" 322 | 323 | [[package]] 324 | name = "joblib" 325 | version = "1.1.0" 326 | description = "Lightweight pipelining with Python functions" 327 | category = "main" 328 | optional = false 329 | python-versions = ">=3.6" 330 | 331 | [[package]] 332 | name = "jsonschema" 333 | version = "4.4.0" 334 | description = "An implementation of JSON Schema validation for Python" 335 | category = "main" 336 | optional = false 337 | python-versions = ">=3.7" 338 | 339 | [package.dependencies] 340 | attrs = ">=17.4.0" 341 | importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} 342 | pyrsistent = ">=0.14.0,<0.17.0 || >0.17.0,<0.17.1 || >0.17.1,<0.17.2 || >0.17.2" 343 | 344 | [package.extras] 345 | format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] 346 | format_nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=1.11)"] 347 | 348 | [[package]] 349 | name = "kiwisolver" 350 | version = "1.4.0" 351 | description = "A fast implementation of the Cassowary constraint solver" 352 | category = "main" 353 | optional = false 354 | python-versions = ">=3.7" 355 | 356 | [[package]] 357 | name = "matplotlib" 358 | version = "3.5.1" 359 | description = "Python plotting package" 360 | category = "main" 361 | optional = false 362 | python-versions = ">=3.7" 363 | 364 | [package.dependencies] 365 | cycler = ">=0.10" 366 | fonttools = ">=4.22.0" 367 | kiwisolver = ">=1.0.1" 368 | numpy = ">=1.17" 369 | packaging = ">=20.0" 370 | pillow = ">=6.2.0" 371 | pyparsing = ">=2.2.1" 372 | python-dateutil = ">=2.7" 373 | setuptools_scm = ">=4" 374 | 375 | [[package]] 376 | name = "mypy" 377 | version = "0.941" 378 | description = "Optional static typing for Python" 379 | category = "dev" 380 | optional = false 381 | python-versions = ">=3.6" 382 | 383 | [package.dependencies] 384 | mypy-extensions = ">=0.4.3" 385 | tomli = ">=1.1.0" 386 | typing-extensions = ">=3.10" 387 | 388 | [package.extras] 389 | dmypy = ["psutil (>=4.0)"] 390 | python2 = ["typed-ast (>=1.4.0,<2)"] 391 | reports = ["lxml"] 392 | 393 | [[package]] 394 | name = "mypy-extensions" 395 | version = "0.4.3" 396 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 397 | category = "dev" 398 | optional = false 399 | python-versions = "*" 400 | 401 | [[package]] 402 | name = "numpy" 403 | version = "1.22.3" 404 | description = "NumPy is the fundamental package for array computing with Python." 405 | category = "main" 406 | optional = false 407 | python-versions = ">=3.8" 408 | 409 | [[package]] 410 | name = "packaging" 411 | version = "21.3" 412 | description = "Core utilities for Python packages" 413 | category = "main" 414 | optional = false 415 | python-versions = ">=3.6" 416 | 417 | [package.dependencies] 418 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 419 | 420 | [[package]] 421 | name = "pandas" 422 | version = "1.4.1" 423 | description = "Powerful data structures for data analysis, time series, and statistics" 424 | category = "main" 425 | optional = false 426 | python-versions = ">=3.8" 427 | 428 | [package.dependencies] 429 | numpy = [ 430 | {version = ">=1.18.5", markers = "platform_machine != \"aarch64\" and platform_machine != \"arm64\" and python_version < \"3.10\""}, 431 | {version = ">=1.19.2", markers = "platform_machine == \"aarch64\" and python_version < \"3.10\""}, 432 | {version = ">=1.20.0", markers = "platform_machine == \"arm64\" and python_version < \"3.10\""}, 433 | {version = ">=1.21.0", markers = "python_version >= \"3.10\""}, 434 | ] 435 | python-dateutil = ">=2.8.1" 436 | pytz = ">=2020.1" 437 | 438 | [package.extras] 439 | test = ["hypothesis (>=5.5.3)", "pytest (>=6.0)", "pytest-xdist (>=1.31)"] 440 | 441 | [[package]] 442 | name = "pathspec" 443 | version = "0.9.0" 444 | description = "Utility library for gitignore style pattern matching of file paths." 445 | category = "dev" 446 | optional = false 447 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 448 | 449 | [[package]] 450 | name = "peewee" 451 | version = "3.14.10" 452 | description = "a little orm" 453 | category = "main" 454 | optional = false 455 | python-versions = "*" 456 | 457 | [[package]] 458 | name = "persist-queue" 459 | version = "0.7.0" 460 | description = "A thread-safe disk based persistent queue in Python." 461 | category = "main" 462 | optional = false 463 | python-versions = "*" 464 | 465 | [package.extras] 466 | extra = ["msgpack (>=0.5.6)"] 467 | 468 | [[package]] 469 | name = "pillow" 470 | version = "9.0.1" 471 | description = "Python Imaging Library (Fork)" 472 | category = "main" 473 | optional = false 474 | python-versions = ">=3.7" 475 | 476 | [[package]] 477 | name = "platformdirs" 478 | version = "2.5.1" 479 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 480 | category = "dev" 481 | optional = false 482 | python-versions = ">=3.7" 483 | 484 | [package.extras] 485 | docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] 486 | test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] 487 | 488 | [[package]] 489 | name = "pluggy" 490 | version = "1.0.0" 491 | description = "plugin and hook calling mechanisms for python" 492 | category = "dev" 493 | optional = false 494 | python-versions = ">=3.6" 495 | 496 | [package.extras] 497 | dev = ["pre-commit", "tox"] 498 | testing = ["pytest", "pytest-benchmark"] 499 | 500 | [[package]] 501 | name = "protobuf" 502 | version = "3.19.4" 503 | description = "Protocol Buffers" 504 | category = "main" 505 | optional = false 506 | python-versions = ">=3.5" 507 | 508 | [[package]] 509 | name = "py" 510 | version = "1.11.0" 511 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 512 | category = "dev" 513 | optional = false 514 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 515 | 516 | [[package]] 517 | name = "pyasn1" 518 | version = "0.4.8" 519 | description = "ASN.1 types and codecs" 520 | category = "main" 521 | optional = false 522 | python-versions = "*" 523 | 524 | [[package]] 525 | name = "pyasn1-modules" 526 | version = "0.2.8" 527 | description = "A collection of ASN.1-based protocols modules." 528 | category = "main" 529 | optional = false 530 | python-versions = "*" 531 | 532 | [package.dependencies] 533 | pyasn1 = ">=0.4.6,<0.5.0" 534 | 535 | [[package]] 536 | name = "pydash" 537 | version = "5.1.0" 538 | description = "The kitchen sink of Python utility libraries for doing \"stuff\" in a functional way. Based on the Lo-Dash Javascript library." 539 | category = "main" 540 | optional = false 541 | python-versions = ">=3.6" 542 | 543 | [package.extras] 544 | dev = ["black", "coverage", "docformatter", "flake8", "flake8-black", "flake8-bugbear", "flake8-isort", "invoke", "isort", "pylint", "pytest", "pytest-cov", "pytest-flake8", "pytest-pylint", "sphinx", "sphinx-rtd-theme", "tox", "twine", "wheel"] 545 | 546 | [[package]] 547 | name = "pyparsing" 548 | version = "3.0.7" 549 | description = "Python parsing module" 550 | category = "main" 551 | optional = false 552 | python-versions = ">=3.6" 553 | 554 | [package.extras] 555 | diagrams = ["jinja2", "railroad-diagrams"] 556 | 557 | [[package]] 558 | name = "pyrsistent" 559 | version = "0.18.1" 560 | description = "Persistent/Functional/Immutable data structures" 561 | category = "main" 562 | optional = false 563 | python-versions = ">=3.7" 564 | 565 | [[package]] 566 | name = "pytest" 567 | version = "7.1.1" 568 | description = "pytest: simple powerful testing with Python" 569 | category = "dev" 570 | optional = false 571 | python-versions = ">=3.7" 572 | 573 | [package.dependencies] 574 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 575 | attrs = ">=19.2.0" 576 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 577 | iniconfig = "*" 578 | packaging = "*" 579 | pluggy = ">=0.12,<2.0" 580 | py = ">=1.8.2" 581 | tomli = ">=1.0.0" 582 | 583 | [package.extras] 584 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 585 | 586 | [[package]] 587 | name = "pytest-cov" 588 | version = "3.0.0" 589 | description = "Pytest plugin for measuring coverage." 590 | category = "dev" 591 | optional = false 592 | python-versions = ">=3.6" 593 | 594 | [package.dependencies] 595 | coverage = {version = ">=5.2.1", extras = ["toml"]} 596 | pytest = ">=4.6" 597 | 598 | [package.extras] 599 | testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] 600 | 601 | [[package]] 602 | name = "python-dateutil" 603 | version = "2.8.2" 604 | description = "Extensions to the standard Python datetime module" 605 | category = "main" 606 | optional = false 607 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 608 | 609 | [package.dependencies] 610 | six = ">=1.5" 611 | 612 | [[package]] 613 | name = "python-json-logger" 614 | version = "2.0.2" 615 | description = "A python library adding a json log formatter" 616 | category = "main" 617 | optional = false 618 | python-versions = ">=3.5" 619 | 620 | [[package]] 621 | name = "pytz" 622 | version = "2022.1" 623 | description = "World timezone definitions, modern and historical" 624 | category = "main" 625 | optional = false 626 | python-versions = "*" 627 | 628 | [[package]] 629 | name = "requests" 630 | version = "2.27.1" 631 | description = "Python HTTP for Humans." 632 | category = "main" 633 | optional = false 634 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 635 | 636 | [package.dependencies] 637 | certifi = ">=2017.4.17" 638 | charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} 639 | idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} 640 | urllib3 = ">=1.21.1,<1.27" 641 | 642 | [package.extras] 643 | socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] 644 | use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] 645 | 646 | [[package]] 647 | name = "rfc3339-validator" 648 | version = "0.1.4" 649 | description = "A pure python RFC3339 validator" 650 | category = "main" 651 | optional = false 652 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 653 | 654 | [package.dependencies] 655 | six = "*" 656 | 657 | [[package]] 658 | name = "rsa" 659 | version = "4.8" 660 | description = "Pure-Python RSA implementation" 661 | category = "main" 662 | optional = false 663 | python-versions = ">=3.6,<4" 664 | 665 | [package.dependencies] 666 | pyasn1 = ">=0.1.3" 667 | 668 | [[package]] 669 | name = "setuptools-scm" 670 | version = "6.4.2" 671 | description = "the blessed package to manage your versions by scm tags" 672 | category = "main" 673 | optional = false 674 | python-versions = ">=3.6" 675 | 676 | [package.dependencies] 677 | packaging = ">=20.0" 678 | tomli = ">=1.0.0" 679 | 680 | [package.extras] 681 | test = ["pytest (>=6.2)", "virtualenv (>20)"] 682 | toml = ["setuptools (>=42)"] 683 | 684 | [[package]] 685 | name = "six" 686 | version = "1.16.0" 687 | description = "Python 2 and 3 compatibility utilities" 688 | category = "main" 689 | optional = false 690 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 691 | 692 | [[package]] 693 | name = "strict-rfc3339" 694 | version = "0.7" 695 | description = "Strict, simple, lightweight RFC3339 functions" 696 | category = "main" 697 | optional = false 698 | python-versions = "*" 699 | 700 | [[package]] 701 | name = "tabulate" 702 | version = "0.8.9" 703 | description = "Pretty-print tabular data" 704 | category = "main" 705 | optional = false 706 | python-versions = "*" 707 | 708 | [package.extras] 709 | widechars = ["wcwidth"] 710 | 711 | [[package]] 712 | name = "takethetime" 713 | version = "0.3.1" 714 | description = "Take The Time, a time-taking library for Python" 715 | category = "main" 716 | optional = false 717 | python-versions = "*" 718 | 719 | [[package]] 720 | name = "timeslot" 721 | version = "0.1.2" 722 | description = "Data type for representing time slots with a start and end." 723 | category = "main" 724 | optional = false 725 | python-versions = ">=3.6,<4.0" 726 | 727 | [[package]] 728 | name = "toml" 729 | version = "0.10.2" 730 | description = "Python Library for Tom's Obvious, Minimal Language" 731 | category = "main" 732 | optional = false 733 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 734 | 735 | [[package]] 736 | name = "tomli" 737 | version = "2.0.1" 738 | description = "A lil' TOML parser" 739 | category = "main" 740 | optional = false 741 | python-versions = ">=3.7" 742 | 743 | [[package]] 744 | name = "tomlkit" 745 | version = "0.10.0" 746 | description = "Style preserving TOML library" 747 | category = "main" 748 | optional = false 749 | python-versions = ">=3.6,<4.0" 750 | 751 | [[package]] 752 | name = "typing-extensions" 753 | version = "4.1.1" 754 | description = "Backported and Experimental Type Hints for Python 3.6+" 755 | category = "main" 756 | optional = false 757 | python-versions = ">=3.6" 758 | 759 | [[package]] 760 | name = "uritemplate" 761 | version = "4.1.1" 762 | description = "Implementation of RFC 6570 URI Templates" 763 | category = "main" 764 | optional = false 765 | python-versions = ">=3.6" 766 | 767 | [[package]] 768 | name = "urllib3" 769 | version = "1.26.9" 770 | description = "HTTP library with thread-safe connection pooling, file post, and more." 771 | category = "main" 772 | optional = false 773 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 774 | 775 | [package.extras] 776 | brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] 777 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 778 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 779 | 780 | [[package]] 781 | name = "zipp" 782 | version = "3.7.0" 783 | description = "Backport of pathlib-compatible object wrapper for zip files" 784 | category = "main" 785 | optional = false 786 | python-versions = ">=3.7" 787 | 788 | [package.extras] 789 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 790 | testing = ["pytest (>=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"] 791 | 792 | [metadata] 793 | lock-version = "1.1" 794 | python-versions = "^3.8" 795 | content-hash = "7e04fe95b63785b22fb260af1387a474007dd6bebaeddafe4c63e387f07664e4" 796 | 797 | [metadata.files] 798 | appdirs = [ 799 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 800 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 801 | ] 802 | atomicwrites = [ 803 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 804 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 805 | ] 806 | attrs = [ 807 | {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, 808 | {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, 809 | ] 810 | aw-client = [ 811 | {file = "aw-client-0.5.9.tar.gz", hash = "sha256:fd93dc58582492fca316c59aeaa4d24651284ef94c9aed92d94b4afd812a6cfa"}, 812 | {file = "aw_client-0.5.9-py3-none-any.whl", hash = "sha256:1c2449bbaecfb42e029268c67d9eea8cbc4b807e80e59a0389c1b771b43d62de"}, 813 | ] 814 | aw-core = [ 815 | {file = "aw-core-0.5.9.tar.gz", hash = "sha256:81149f6a971f192aa91183066f78f2bdb4c14de826f79b6607b3b52984f2ddb8"}, 816 | {file = "aw_core-0.5.9-py3-none-any.whl", hash = "sha256:4f725d42ce11f5f7f1f6883811a925214bdaf114a4e68cfa58ce8a66cc23cfc6"}, 817 | ] 818 | black = [ 819 | {file = "black-22.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1297c63b9e1b96a3d0da2d85d11cd9bf8664251fd69ddac068b98dc4f34f73b6"}, 820 | {file = "black-22.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2ff96450d3ad9ea499fc4c60e425a1439c2120cbbc1ab959ff20f7c76ec7e866"}, 821 | {file = "black-22.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e21e1f1efa65a50e3960edd068b6ae6d64ad6235bd8bfea116a03b21836af71"}, 822 | {file = "black-22.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f69158a7d120fd641d1fa9a921d898e20d52e44a74a6fbbcc570a62a6bc8ab"}, 823 | {file = "black-22.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:228b5ae2c8e3d6227e4bde5920d2fc66cc3400fde7bcc74f480cb07ef0b570d5"}, 824 | {file = "black-22.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b1a5ed73ab4c482208d20434f700d514f66ffe2840f63a6252ecc43a9bc77e8a"}, 825 | {file = "black-22.1.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35944b7100af4a985abfcaa860b06af15590deb1f392f06c8683b4381e8eeaf0"}, 826 | {file = "black-22.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7835fee5238fc0a0baf6c9268fb816b5f5cd9b8793423a75e8cd663c48d073ba"}, 827 | {file = "black-22.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dae63f2dbf82882fa3b2a3c49c32bffe144970a573cd68d247af6560fc493ae1"}, 828 | {file = "black-22.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fa1db02410b1924b6749c245ab38d30621564e658297484952f3d8a39fce7e8"}, 829 | {file = "black-22.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c8226f50b8c34a14608b848dc23a46e5d08397d009446353dad45e04af0c8e28"}, 830 | {file = "black-22.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2d6f331c02f0f40aa51a22e479c8209d37fcd520c77721c034517d44eecf5912"}, 831 | {file = "black-22.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:742ce9af3086e5bd07e58c8feb09dbb2b047b7f566eb5f5bc63fd455814979f3"}, 832 | {file = "black-22.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fdb8754b453fb15fad3f72cd9cad3e16776f0964d67cf30ebcbf10327a3777a3"}, 833 | {file = "black-22.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5660feab44c2e3cb24b2419b998846cbb01c23c7fe645fee45087efa3da2d61"}, 834 | {file = "black-22.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:6f2f01381f91c1efb1451998bd65a129b3ed6f64f79663a55fe0e9b74a5f81fd"}, 835 | {file = "black-22.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:efbadd9b52c060a8fc3b9658744091cb33c31f830b3f074422ed27bad2b18e8f"}, 836 | {file = "black-22.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8871fcb4b447206904932b54b567923e5be802b9b19b744fdff092bd2f3118d0"}, 837 | {file = "black-22.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ccad888050f5393f0d6029deea2a33e5ae371fd182a697313bdbd835d3edaf9c"}, 838 | {file = "black-22.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07e5c049442d7ca1a2fc273c79d1aecbbf1bc858f62e8184abe1ad175c4f7cc2"}, 839 | {file = "black-22.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:373922fc66676133ddc3e754e4509196a8c392fec3f5ca4486673e685a421321"}, 840 | {file = "black-22.1.0-py3-none-any.whl", hash = "sha256:3524739d76b6b3ed1132422bf9d82123cd1705086723bc3e235ca39fd21c667d"}, 841 | {file = "black-22.1.0.tar.gz", hash = "sha256:a7c0192d35635f6fc1174be575cb7915e92e5dd629ee79fdaf0dcfa41a80afb5"}, 842 | ] 843 | cachetools = [ 844 | {file = "cachetools-5.0.0-py3-none-any.whl", hash = "sha256:8fecd4203a38af17928be7b90689d8083603073622229ca7077b72d8e5a976e4"}, 845 | {file = "cachetools-5.0.0.tar.gz", hash = "sha256:486471dfa8799eb7ec503a8059e263db000cdda20075ce5e48903087f79d5fd6"}, 846 | ] 847 | certifi = [ 848 | {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, 849 | {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, 850 | ] 851 | charset-normalizer = [ 852 | {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, 853 | {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, 854 | ] 855 | click = [ 856 | {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, 857 | {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, 858 | ] 859 | colorama = [ 860 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 861 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 862 | ] 863 | coverage = [ 864 | {file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"}, 865 | {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"}, 866 | {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"}, 867 | {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"}, 868 | {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"}, 869 | {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"}, 870 | {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"}, 871 | {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"}, 872 | {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"}, 873 | {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"}, 874 | {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"}, 875 | {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"}, 876 | {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"}, 877 | {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"}, 878 | {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"}, 879 | {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"}, 880 | {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"}, 881 | {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"}, 882 | {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"}, 883 | {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"}, 884 | {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"}, 885 | {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"}, 886 | {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"}, 887 | {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"}, 888 | {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"}, 889 | {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"}, 890 | {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"}, 891 | {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"}, 892 | {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"}, 893 | {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"}, 894 | {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"}, 895 | {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"}, 896 | {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"}, 897 | {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"}, 898 | {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"}, 899 | {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"}, 900 | {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"}, 901 | {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"}, 902 | {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"}, 903 | {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"}, 904 | {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, 905 | ] 906 | cycler = [ 907 | {file = "cycler-0.11.0-py3-none-any.whl", hash = "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3"}, 908 | {file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"}, 909 | ] 910 | deprecation = [ 911 | {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"}, 912 | {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"}, 913 | ] 914 | fonttools = [ 915 | {file = "fonttools-4.31.2-py3-none-any.whl", hash = "sha256:2df636a3f402ef14593c6811dac0609563b8c374bd7850e76919eb51ea205426"}, 916 | {file = "fonttools-4.31.2.zip", hash = "sha256:236b29aee6b113e8f7bee28779c1230a86ad2aac9a74a31b0aedf57e7dfb62a4"}, 917 | ] 918 | google-api-core = [ 919 | {file = "google-api-core-2.7.1.tar.gz", hash = "sha256:b0fa577e512f0c8e063386b974718b8614586a798c5894ed34bedf256d9dae24"}, 920 | {file = "google_api_core-2.7.1-py3-none-any.whl", hash = "sha256:6be1fc59e2a7ba9f66808bbc22f976f81e4c3e7ab20fa0620ce42686288787d0"}, 921 | ] 922 | google-api-python-client = [ 923 | {file = "google-api-python-client-2.42.0.tar.gz", hash = "sha256:7bf58b665b635d06adb47797a9a4f73482e739e02ed036cd960dc7c183361f67"}, 924 | {file = "google_api_python_client-2.42.0-py2.py3-none-any.whl", hash = "sha256:303f5e7005b26e5ca4d2f04489545cdc482ca558e8932e528217e11127c17cd8"}, 925 | ] 926 | google-auth = [ 927 | {file = "google-auth-2.6.2.tar.gz", hash = "sha256:60d449f8142c742db760f4c0be39121bc8d9be855555d784c252deaca1ced3f5"}, 928 | {file = "google_auth-2.6.2-py2.py3-none-any.whl", hash = "sha256:3ba4d63cb29c1e6d5ffcc1c0623c03cf02ede6240a072f213084749574e691ab"}, 929 | ] 930 | google-auth-httplib2 = [ 931 | {file = "google-auth-httplib2-0.1.0.tar.gz", hash = "sha256:a07c39fd632becacd3f07718dfd6021bf396978f03ad3ce4321d060015cc30ac"}, 932 | {file = "google_auth_httplib2-0.1.0-py2.py3-none-any.whl", hash = "sha256:31e49c36c6b5643b57e82617cb3e021e3e1d2df9da63af67252c02fa9c1f4a10"}, 933 | ] 934 | googleapis-common-protos = [ 935 | {file = "googleapis-common-protos-1.56.0.tar.gz", hash = "sha256:4007500795bcfc269d279f0f7d253ae18d6dc1ff5d5a73613ffe452038b1ec5f"}, 936 | {file = "googleapis_common_protos-1.56.0-py2.py3-none-any.whl", hash = "sha256:60220c89b8bd5272159bed4929ecdc1243ae1f73437883a499a44a1cbc084086"}, 937 | ] 938 | httplib2 = [ 939 | {file = "httplib2-0.20.4-py3-none-any.whl", hash = "sha256:8b6a905cb1c79eefd03f8669fd993c36dc341f7c558f056cb5a33b5c2f458543"}, 940 | {file = "httplib2-0.20.4.tar.gz", hash = "sha256:58a98e45b4b1a48273073f905d2961666ecf0fbac4250ea5b47aef259eb5c585"}, 941 | ] 942 | idna = [ 943 | {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, 944 | {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, 945 | ] 946 | importlib-resources = [ 947 | {file = "importlib_resources-5.4.0-py3-none-any.whl", hash = "sha256:33a95faed5fc19b4bc16b29a6eeae248a3fe69dd55d4d229d2b480e23eeaad45"}, 948 | {file = "importlib_resources-5.4.0.tar.gz", hash = "sha256:d756e2f85dd4de2ba89be0b21dba2a3bbec2e871a42a3a16719258a11f87506b"}, 949 | ] 950 | iniconfig = [ 951 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 952 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 953 | ] 954 | iso8601 = [ 955 | {file = "iso8601-1.0.2-py3-none-any.whl", hash = "sha256:d7bc01b1c2a43b259570bb307f057abc578786ea734ba2b87b836c5efc5bd443"}, 956 | {file = "iso8601-1.0.2.tar.gz", hash = "sha256:27f503220e6845d9db954fb212b95b0362d8b7e6c1b2326a87061c3de93594b1"}, 957 | ] 958 | joblib = [ 959 | {file = "joblib-1.1.0-py2.py3-none-any.whl", hash = "sha256:f21f109b3c7ff9d95f8387f752d0d9c34a02aa2f7060c2135f465da0e5160ff6"}, 960 | {file = "joblib-1.1.0.tar.gz", hash = "sha256:4158fcecd13733f8be669be0683b96ebdbbd38d23559f54dca7205aea1bf1e35"}, 961 | ] 962 | jsonschema = [ 963 | {file = "jsonschema-4.4.0-py3-none-any.whl", hash = "sha256:77281a1f71684953ee8b3d488371b162419767973789272434bbc3f29d9c8823"}, 964 | {file = "jsonschema-4.4.0.tar.gz", hash = "sha256:636694eb41b3535ed608fe04129f26542b59ed99808b4f688aa32dcf55317a83"}, 965 | ] 966 | kiwisolver = [ 967 | {file = "kiwisolver-1.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:70e7b7a4ebeddef423115ea31857732fc04e0f38dd1e6385e1af05b6164a3d0f"}, 968 | {file = "kiwisolver-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:384b5076b2c0172003abca9ba8b8c5efcaaffd31616f3f5e0a09dcc34772d012"}, 969 | {file = "kiwisolver-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:334a7e3d498a0a791245f0964c746d0414e9b13aef73237f0d798a2101fdbae9"}, 970 | {file = "kiwisolver-1.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:734e943ae519cdb8534d9053f478417c525ec921c06896ec7119e65d9ea4a687"}, 971 | {file = "kiwisolver-1.4.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:65cbdbe14dc5988e362eb15e02dd24c6724238cb235132f812f1e3a29a61a3de"}, 972 | {file = "kiwisolver-1.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf0080449d6ea39b817d85abd2c20d2d42fd9b1587544d64371d28d93c379cf"}, 973 | {file = "kiwisolver-1.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd0223a3a4ddcc0d0e06c6cfeb0adde2bc19c08b4c7fc79d48dac2486a4b115b"}, 974 | {file = "kiwisolver-1.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed30c5e58e578a2981c67346b2569e04120d1b80fa6906c207fe824d24603313"}, 975 | {file = "kiwisolver-1.4.0-cp310-cp310-win32.whl", hash = "sha256:ed937691f522cc2362c280c903837a4e35195659b9935b598e3cd448db863605"}, 976 | {file = "kiwisolver-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:576ba51b9f4e4d0d583c1cd257f53397bdc5e66a5e49fe68712f658426115777"}, 977 | {file = "kiwisolver-1.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2467fe5fff6ed2a728e10dca9b1f37e9b911ca5b228a7d8990c8e3abf80c1724"}, 978 | {file = "kiwisolver-1.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff7ae6fb6dce2f520b2d46efc801605fa1378fb19bb4580aebc6174eab05a0"}, 979 | {file = "kiwisolver-1.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:313724e85fd14d581a939fa02424f4dc772fd914bc04499a8a6377d47313b966"}, 980 | {file = "kiwisolver-1.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb997d1631b20745b18674d68dd6f1d9d45db512efd5fe0f162a5d4a6bbdd211"}, 981 | {file = "kiwisolver-1.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97372c837add54e3e64a811464b14bb01428c4e9256072b6296f04157ea23246"}, 982 | {file = "kiwisolver-1.4.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4471a48f53d20d49f263ca888aab77b754525ef35e6767657e1a44a724a8b0af"}, 983 | {file = "kiwisolver-1.4.0-cp37-cp37m-win32.whl", hash = "sha256:1cf8c81e8a5fb4f5dcbd473fdb619b895313d29b7c60e4545827dcc6efbd8efc"}, 984 | {file = "kiwisolver-1.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:87367ba1ad3819f7189fe8faff5f75a7603f526576033e7b86e10b598f8790b2"}, 985 | {file = "kiwisolver-1.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:139c75216e5875ee5f8f4f7adcc3cd339f46f0d66bda2e10d8d21386d635476f"}, 986 | {file = "kiwisolver-1.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:895b2df026006ff7434b03ca495983d0d26da96f6d58414c77d616747ee77e34"}, 987 | {file = "kiwisolver-1.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cbf9aa926de224af15c974750fecdc7d2c0043428372acaaf61216e202abbf21"}, 988 | {file = "kiwisolver-1.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cd1f81bc35ec24cb82a7d0b805521e3d71b25b8a493d5810d18dc29644c6ef8"}, 989 | {file = "kiwisolver-1.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199f32bf6f3d3e2246024326497513c5c49c62aecee86f0ac019f5991978d505"}, 990 | {file = "kiwisolver-1.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af6a7c956a45ee721e4263f5823e1a3b2e6b21a7e2b3646b3794e000620609d0"}, 991 | {file = "kiwisolver-1.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3891527ec51b0365bb50de9bf826ce3d5b1adc276685b2779889762437bbd359"}, 992 | {file = "kiwisolver-1.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:14f43edc25daa0646d4b4e86c2ebdd32d785ab73a65a570130a3d234a4554b07"}, 993 | {file = "kiwisolver-1.4.0-cp38-cp38-win32.whl", hash = "sha256:5ecf82bb25cec7df4bfcf37afe49f6f6202b4fa4029be7cb0848ed319c72d356"}, 994 | {file = "kiwisolver-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:34e2e39a219b203fa3a82af5b9f8d386a8718677de7a9b82a9634e292a8f4e0a"}, 995 | {file = "kiwisolver-1.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6c19457f58941da61681efaabd5b1c37893108a2f922b9b19538f6921911186d"}, 996 | {file = "kiwisolver-1.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a0a6f3d5063e7fd6662e4773778ad2cb36e598abc6eb171af4a072ca86b441d0"}, 997 | {file = "kiwisolver-1.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:676f9fac93f97f529dc80b5d6731099fad337549408e8bdd929343b7cb679797"}, 998 | {file = "kiwisolver-1.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4b70f0729947d6327cd659e1b3477ced44a317a4ba441238b2a3642990f0ebd7"}, 999 | {file = "kiwisolver-1.4.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:925a32900fc16430ba0dec2c0fca2e776eaf2fdc0930d5552be0a59e23304001"}, 1000 | {file = "kiwisolver-1.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17ec8bd4e162fd0a8723467395c5bb16fd665a528b78e9339886c82965ed8efb"}, 1001 | {file = "kiwisolver-1.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b4d1db32a4f1682df1480fd68eb1400235ac8f9ad8932e1624fdb23eb891904"}, 1002 | {file = "kiwisolver-1.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38ebc0cb30ed2f59bd15e23591a53698005123e90e880f1af4600fcdbe4912e1"}, 1003 | {file = "kiwisolver-1.4.0-cp39-cp39-win32.whl", hash = "sha256:8f63b981678ca894bb665bcd5043bde2c9ba600e69df730c1ceeadd73ddbcb8c"}, 1004 | {file = "kiwisolver-1.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:b1ff5582bf55e85728119c5a23f695b8e408e15eee7d0f5effe0ee8ad1f8b523"}, 1005 | {file = "kiwisolver-1.4.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c29496625c61e18d97a6f6c2f2a55759ca8290fc21a751bc57824599c431c0d2"}, 1006 | {file = "kiwisolver-1.4.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:71d44a6a59ea53d41e5950a42ec496fa821bd86f292fb3e10aa1b3932ecfc65e"}, 1007 | {file = "kiwisolver-1.4.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf2030bf18c21bf91fa9cf6a403a765519c9168bd7a91ba1d66d5c7f70ded1e"}, 1008 | {file = "kiwisolver-1.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:5ca92de8e48678a2cbbd90adb10773e3553bb9fd1c090bf0dfe5fc3337a181ea"}, 1009 | {file = "kiwisolver-1.4.0.tar.gz", hash = "sha256:7508b01e211178a85d21f1f87029846b77b2404a4c68cbd14748d4d4142fa3b8"}, 1010 | ] 1011 | matplotlib = [ 1012 | {file = "matplotlib-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:456cc8334f6d1124e8ff856b42d2cc1c84335375a16448189999496549f7182b"}, 1013 | {file = "matplotlib-3.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8a77906dc2ef9b67407cec0bdbf08e3971141e535db888974a915be5e1e3efc6"}, 1014 | {file = "matplotlib-3.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e70ae6475cfd0fad3816dcbf6cac536dc6f100f7474be58d59fa306e6e768a4"}, 1015 | {file = "matplotlib-3.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53273c5487d1c19c3bc03b9eb82adaf8456f243b97ed79d09dded747abaf1235"}, 1016 | {file = "matplotlib-3.5.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3b6f3fd0d8ca37861c31e9a7cab71a0ef14c639b4c95654ea1dd153158bf0df"}, 1017 | {file = "matplotlib-3.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8c87cdaf06fd7b2477f68909838ff4176f105064a72ca9d24d3f2a29f73d393"}, 1018 | {file = "matplotlib-3.5.1-cp310-cp310-win32.whl", hash = "sha256:e2f28a07b4f82abb40267864ad7b3a4ed76f1b1663e81c7efc84a9b9248f672f"}, 1019 | {file = "matplotlib-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:d70a32ee1f8b55eed3fd4e892f0286df8cccc7e0475c11d33b5d0a148f5c7599"}, 1020 | {file = "matplotlib-3.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:68fa30cec89b6139dc559ed6ef226c53fd80396da1919a1b5ef672c911aaa767"}, 1021 | {file = "matplotlib-3.5.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e3484d8455af3fdb0424eae1789af61f6a79da0c80079125112fd5c1b604218"}, 1022 | {file = "matplotlib-3.5.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e293b16cf303fe82995e41700d172a58a15efc5331125d08246b520843ef21ee"}, 1023 | {file = "matplotlib-3.5.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e3520a274a0e054e919f5b3279ee5dbccf5311833819ccf3399dab7c83e90a25"}, 1024 | {file = "matplotlib-3.5.1-cp37-cp37m-win32.whl", hash = "sha256:2252bfac85cec7af4a67e494bfccf9080bcba8a0299701eab075f48847cca907"}, 1025 | {file = "matplotlib-3.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:abf67e05a1b7f86583f6ebd01f69b693b9c535276f4e943292e444855870a1b8"}, 1026 | {file = "matplotlib-3.5.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6c094e4bfecd2fa7f9adffd03d8abceed7157c928c2976899de282f3600f0a3d"}, 1027 | {file = "matplotlib-3.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:506b210cc6e66a0d1c2bb765d055f4f6bc2745070fb1129203b67e85bbfa5c18"}, 1028 | {file = "matplotlib-3.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b04fc29bcef04d4e2d626af28d9d892be6aba94856cb46ed52bcb219ceac8943"}, 1029 | {file = "matplotlib-3.5.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:577ed20ec9a18d6bdedb4616f5e9e957b4c08563a9f985563a31fd5b10564d2a"}, 1030 | {file = "matplotlib-3.5.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e486f60db0cd1c8d68464d9484fd2a94011c1ac8593d765d0211f9daba2bd535"}, 1031 | {file = "matplotlib-3.5.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b71f3a7ca935fc759f2aed7cec06cfe10bc3100fadb5dbd9c435b04e557971e1"}, 1032 | {file = "matplotlib-3.5.1-cp38-cp38-win32.whl", hash = "sha256:d24e5bb8028541ce25e59390122f5e48c8506b7e35587e5135efcb6471b4ac6c"}, 1033 | {file = "matplotlib-3.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:778d398c4866d8e36ee3bf833779c940b5f57192fa0a549b3ad67bc4c822771b"}, 1034 | {file = "matplotlib-3.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bb1c613908f11bac270bc7494d68b1ef6e7c224b7a4204d5dacf3522a41e2bc3"}, 1035 | {file = "matplotlib-3.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:edf5e4e1d5fb22c18820e8586fb867455de3b109c309cb4fce3aaed85d9468d1"}, 1036 | {file = "matplotlib-3.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:40e0d7df05e8efe60397c69b467fc8f87a2affeb4d562fe92b72ff8937a2b511"}, 1037 | {file = "matplotlib-3.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a350ca685d9f594123f652ba796ee37219bf72c8e0fc4b471473d87121d6d34"}, 1038 | {file = "matplotlib-3.5.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3e66497cd990b1a130e21919b004da2f1dc112132c01ac78011a90a0f9229778"}, 1039 | {file = "matplotlib-3.5.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:87900c67c0f1728e6db17c6809ec05c025c6624dcf96a8020326ea15378fe8e7"}, 1040 | {file = "matplotlib-3.5.1-cp39-cp39-win32.whl", hash = "sha256:b8a4fb2a0c5afbe9604f8a91d7d0f27b1832c3e0b5e365f95a13015822b4cd65"}, 1041 | {file = "matplotlib-3.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:fe8d40c434a8e2c68d64c6d6a04e77f21791a93ff6afe0dce169597c110d3079"}, 1042 | {file = "matplotlib-3.5.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:34a1fc29f8f96e78ec57a5eff5e8d8b53d3298c3be6df61e7aa9efba26929522"}, 1043 | {file = "matplotlib-3.5.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b19a761b948e939a9e20173aaae76070025f0024fc8f7ba08bef22a5c8573afc"}, 1044 | {file = "matplotlib-3.5.1-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6803299cbf4665eca14428d9e886de62e24f4223ac31ab9c5d6d5339a39782c7"}, 1045 | {file = "matplotlib-3.5.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:14334b9902ec776461c4b8c6516e26b450f7ebe0b3ef8703bf5cdfbbaecf774a"}, 1046 | {file = "matplotlib-3.5.1.tar.gz", hash = "sha256:b2e9810e09c3a47b73ce9cab5a72243a1258f61e7900969097a817232246ce1c"}, 1047 | ] 1048 | mypy = [ 1049 | {file = "mypy-0.941-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:98f61aad0bb54f797b17da5b82f419e6ce214de0aa7e92211ebee9e40eb04276"}, 1050 | {file = "mypy-0.941-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6a8e1f63357851444940351e98fb3252956a15f2cabe3d698316d7a2d1f1f896"}, 1051 | {file = "mypy-0.941-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b30d29251dff4c59b2e5a1fa1bab91ff3e117b4658cb90f76d97702b7a2ae699"}, 1052 | {file = "mypy-0.941-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8eaf55fdf99242a1c8c792247c455565447353914023878beadb79600aac4a2a"}, 1053 | {file = "mypy-0.941-cp310-cp310-win_amd64.whl", hash = "sha256:080097eee5393fd740f32c63f9343580aaa0fb1cda0128fd859dfcf081321c3d"}, 1054 | {file = "mypy-0.941-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f79137d012ff3227866222049af534f25354c07a0d6b9a171dba9f1d6a1fdef4"}, 1055 | {file = "mypy-0.941-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8e5974583a77d630a5868eee18f85ac3093caf76e018c510aeb802b9973304ce"}, 1056 | {file = "mypy-0.941-cp36-cp36m-win_amd64.whl", hash = "sha256:0dd441fbacf48e19dc0c5c42fafa72b8e1a0ba0a39309c1af9c84b9397d9b15a"}, 1057 | {file = "mypy-0.941-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0d3bcbe146247997e03bf030122000998b076b3ac6925b0b6563f46d1ce39b50"}, 1058 | {file = "mypy-0.941-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3bada0cf7b6965627954b3a128903a87cac79a79ccd83b6104912e723ef16c7b"}, 1059 | {file = "mypy-0.941-cp37-cp37m-win_amd64.whl", hash = "sha256:eea10982b798ff0ccc3b9e7e42628f932f552c5845066970e67cd6858655d52c"}, 1060 | {file = "mypy-0.941-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:108f3c7e14a038cf097d2444fa0155462362c6316e3ecb2d70f6dd99cd36084d"}, 1061 | {file = "mypy-0.941-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d61b73c01fc1de799226963f2639af831307fe1556b04b7c25e2b6c267a3bc76"}, 1062 | {file = "mypy-0.941-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:42c216a33d2bdba08098acaf5bae65b0c8196afeb535ef4b870919a788a27259"}, 1063 | {file = "mypy-0.941-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fc5ecff5a3bbfbe20091b1cad82815507f5ae9c380a3a9bf40f740c70ce30a9b"}, 1064 | {file = "mypy-0.941-cp38-cp38-win_amd64.whl", hash = "sha256:bf446223b2e0e4f0a4792938e8d885e8a896834aded5f51be5c3c69566495540"}, 1065 | {file = "mypy-0.941-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:745071762f32f65e77de6df699366d707fad6c132a660d1342077cbf671ef589"}, 1066 | {file = "mypy-0.941-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:465a6ce9ca6268cadfbc27a2a94ddf0412568a6b27640ced229270be4f5d394d"}, 1067 | {file = "mypy-0.941-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d051ce0946521eba48e19b25f27f98e5ce4dbc91fff296de76240c46b4464df0"}, 1068 | {file = "mypy-0.941-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:818cfc51c25a5dbfd0705f3ac1919fff6971eb0c02e6f1a1f6a017a42405a7c0"}, 1069 | {file = "mypy-0.941-cp39-cp39-win_amd64.whl", hash = "sha256:b2ce2788df0c066c2ff4ba7190fa84f18937527c477247e926abeb9b1168b8cc"}, 1070 | {file = "mypy-0.941-py3-none-any.whl", hash = "sha256:3cf77f138efb31727ee7197bc824c9d6d7039204ed96756cc0f9ca7d8e8fc2a4"}, 1071 | {file = "mypy-0.941.tar.gz", hash = "sha256:cbcc691d8b507d54cb2b8521f0a2a3d4daa477f62fe77f0abba41e5febb377b7"}, 1072 | ] 1073 | mypy-extensions = [ 1074 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 1075 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 1076 | ] 1077 | numpy = [ 1078 | {file = "numpy-1.22.3-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:92bfa69cfbdf7dfc3040978ad09a48091143cffb778ec3b03fa170c494118d75"}, 1079 | {file = "numpy-1.22.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8251ed96f38b47b4295b1ae51631de7ffa8260b5b087808ef09a39a9d66c97ab"}, 1080 | {file = "numpy-1.22.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48a3aecd3b997bf452a2dedb11f4e79bc5bfd21a1d4cc760e703c31d57c84b3e"}, 1081 | {file = "numpy-1.22.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3bae1a2ed00e90b3ba5f7bd0a7c7999b55d609e0c54ceb2b076a25e345fa9f4"}, 1082 | {file = "numpy-1.22.3-cp310-cp310-win32.whl", hash = "sha256:f950f8845b480cffe522913d35567e29dd381b0dc7e4ce6a4a9f9156417d2430"}, 1083 | {file = "numpy-1.22.3-cp310-cp310-win_amd64.whl", hash = "sha256:08d9b008d0156c70dc392bb3ab3abb6e7a711383c3247b410b39962263576cd4"}, 1084 | {file = "numpy-1.22.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:201b4d0552831f7250a08d3b38de0d989d6f6e4658b709a02a73c524ccc6ffce"}, 1085 | {file = "numpy-1.22.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f8c1f39caad2c896bc0018f699882b345b2a63708008be29b1f355ebf6f933fe"}, 1086 | {file = "numpy-1.22.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:568dfd16224abddafb1cbcce2ff14f522abe037268514dd7e42c6776a1c3f8e5"}, 1087 | {file = "numpy-1.22.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ca688e1b9b95d80250bca34b11a05e389b1420d00e87a0d12dc45f131f704a1"}, 1088 | {file = "numpy-1.22.3-cp38-cp38-win32.whl", hash = "sha256:e7927a589df200c5e23c57970bafbd0cd322459aa7b1ff73b7c2e84d6e3eae62"}, 1089 | {file = "numpy-1.22.3-cp38-cp38-win_amd64.whl", hash = "sha256:07a8c89a04997625236c5ecb7afe35a02af3896c8aa01890a849913a2309c676"}, 1090 | {file = "numpy-1.22.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:2c10a93606e0b4b95c9b04b77dc349b398fdfbda382d2a39ba5a822f669a0123"}, 1091 | {file = "numpy-1.22.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fade0d4f4d292b6f39951b6836d7a3c7ef5b2347f3c420cd9820a1d90d794802"}, 1092 | {file = "numpy-1.22.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bfb1bb598e8229c2d5d48db1860bcf4311337864ea3efdbe1171fb0c5da515d"}, 1093 | {file = "numpy-1.22.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97098b95aa4e418529099c26558eeb8486e66bd1e53a6b606d684d0c3616b168"}, 1094 | {file = "numpy-1.22.3-cp39-cp39-win32.whl", hash = "sha256:fdf3c08bce27132395d3c3ba1503cac12e17282358cb4bddc25cc46b0aca07aa"}, 1095 | {file = "numpy-1.22.3-cp39-cp39-win_amd64.whl", hash = "sha256:639b54cdf6aa4f82fe37ebf70401bbb74b8508fddcf4797f9fe59615b8c5813a"}, 1096 | {file = "numpy-1.22.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c34ea7e9d13a70bf2ab64a2532fe149a9aced424cd05a2c4ba662fd989e3e45f"}, 1097 | {file = "numpy-1.22.3.zip", hash = "sha256:dbc7601a3b7472d559dc7b933b18b4b66f9aa7452c120e87dfb33d02008c8a18"}, 1098 | ] 1099 | packaging = [ 1100 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 1101 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 1102 | ] 1103 | pandas = [ 1104 | {file = "pandas-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3dfb32ed50122fe8c5e7f2b8d97387edd742cc78f9ec36f007ee126cd3720907"}, 1105 | {file = "pandas-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0259cd11e7e6125aaea3af823b80444f3adad6149ff4c97fef760093598b3e34"}, 1106 | {file = "pandas-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96e9ece5759f9b47ae43794b6359bbc54805d76e573b161ae770c1ea59393106"}, 1107 | {file = "pandas-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:508c99debccd15790d526ce6b1624b97a5e1e4ca5b871319fb0ebfd46b8f4dad"}, 1108 | {file = "pandas-1.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6a7bbbb7950063bfc942f8794bc3e31697c020a14f1cd8905fc1d28ec674a01"}, 1109 | {file = "pandas-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:c614001129b2a5add5e3677c3a213a9e6fd376204cb8d17c04e84ff7dfc02a73"}, 1110 | {file = "pandas-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:4e1176f45981c8ccc8161bc036916c004ca51037a7ed73f2d2a9857e6dbe654f"}, 1111 | {file = "pandas-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bbb15ad79050e8b8d39ec40dd96a30cd09b886a2ae8848d0df1abba4d5502a67"}, 1112 | {file = "pandas-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6d6ad1da00c7cc7d8dd1559a6ba59ba3973be6b15722d49738b2be0977eb8a0c"}, 1113 | {file = "pandas-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:358b0bc98a5ff067132d23bf7a2242ee95db9ea5b7bbc401cf79205f11502fd3"}, 1114 | {file = "pandas-1.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6105af6533f8b63a43ea9f08a2ede04e8f43e49daef0209ab0d30352bcf08bee"}, 1115 | {file = "pandas-1.4.1-cp38-cp38-win32.whl", hash = "sha256:04dd15d9db538470900c851498e532ef28d4e56bfe72c9523acb32042de43dfb"}, 1116 | {file = "pandas-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:1b384516dbb4e6aae30e3464c2e77c563da5980440fbdfbd0968e3942f8f9d70"}, 1117 | {file = "pandas-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f02e85e6d832be37d7f16cf6ac8bb26b519ace3e5f3235564a91c7f658ab2a43"}, 1118 | {file = "pandas-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0b1a13f647e4209ed7dbb5da3497891d0045da9785327530ab696417ef478f84"}, 1119 | {file = "pandas-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:19f7c632436b1b4f84615c3b127bbd7bc603db95e3d4332ed259dc815c9aaa26"}, 1120 | {file = "pandas-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ea47ba1d6f359680130bd29af497333be6110de8f4c35b9211eec5a5a9630fa"}, 1121 | {file = "pandas-1.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e5a7a1e0ecaac652326af627a3eca84886da9e667d68286866d4e33f6547caf"}, 1122 | {file = "pandas-1.4.1-cp39-cp39-win32.whl", hash = "sha256:1d85d5f6be66dfd6d1d8d13b9535e342a2214260f1852654b19fa4d7b8d1218b"}, 1123 | {file = "pandas-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:3129a35d9dad1d80c234dd78f8f03141b914395d23f97cf92a366dcd19f8f8bf"}, 1124 | {file = "pandas-1.4.1.tar.gz", hash = "sha256:8db93ec98ac7cb5f8ac1420c10f5e3c43533153f253fe7fb6d891cf5aa2b80d2"}, 1125 | ] 1126 | pathspec = [ 1127 | {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, 1128 | {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, 1129 | ] 1130 | peewee = [ 1131 | {file = "peewee-3.14.10.tar.gz", hash = "sha256:23271422b332c82d30c92597dee905ee831b56c6d99c33e05901e6891c75fe15"}, 1132 | ] 1133 | persist-queue = [ 1134 | {file = "persist-queue-0.7.0.tar.gz", hash = "sha256:e85452d204536fdfc707a72290008dca376b636088a9d5de5eab52cdfde06142"}, 1135 | {file = "persist_queue-0.7.0-py2.py3-none-any.whl", hash = "sha256:2571f1c235daf5ebda7bd826de010cc1a867fc14ae6c223f86199f3a0c96c09c"}, 1136 | ] 1137 | pillow = [ 1138 | {file = "Pillow-9.0.1-1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5d24e1d674dd9d72c66ad3ea9131322819ff86250b30dc5821cbafcfa0b96b4"}, 1139 | {file = "Pillow-9.0.1-1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2632d0f846b7c7600edf53c48f8f9f1e13e62f66a6dbc15191029d950bfed976"}, 1140 | {file = "Pillow-9.0.1-1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b9618823bd237c0d2575283f2939655f54d51b4527ec3972907a927acbcc5bfc"}, 1141 | {file = "Pillow-9.0.1-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:9bfdb82cdfeccec50aad441afc332faf8606dfa5e8efd18a6692b5d6e79f00fd"}, 1142 | {file = "Pillow-9.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5100b45a4638e3c00e4d2320d3193bdabb2d75e79793af7c3eb139e4f569f16f"}, 1143 | {file = "Pillow-9.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:528a2a692c65dd5cafc130de286030af251d2ee0483a5bf50c9348aefe834e8a"}, 1144 | {file = "Pillow-9.0.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f29d831e2151e0b7b39981756d201f7108d3d215896212ffe2e992d06bfe049"}, 1145 | {file = "Pillow-9.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:855c583f268edde09474b081e3ddcd5cf3b20c12f26e0d434e1386cc5d318e7a"}, 1146 | {file = "Pillow-9.0.1-cp310-cp310-win32.whl", hash = "sha256:d9d7942b624b04b895cb95af03a23407f17646815495ce4547f0e60e0b06f58e"}, 1147 | {file = "Pillow-9.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:81c4b81611e3a3cb30e59b0cf05b888c675f97e3adb2c8672c3154047980726b"}, 1148 | {file = "Pillow-9.0.1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:413ce0bbf9fc6278b2d63309dfeefe452835e1c78398efb431bab0672fe9274e"}, 1149 | {file = "Pillow-9.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80fe64a6deb6fcfdf7b8386f2cf216d329be6f2781f7d90304351811fb591360"}, 1150 | {file = "Pillow-9.0.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cef9c85ccbe9bee00909758936ea841ef12035296c748aaceee535969e27d31b"}, 1151 | {file = "Pillow-9.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d19397351f73a88904ad1aee421e800fe4bbcd1aeee6435fb62d0a05ccd1030"}, 1152 | {file = "Pillow-9.0.1-cp37-cp37m-win32.whl", hash = "sha256:d21237d0cd37acded35154e29aec853e945950321dd2ffd1a7d86fe686814669"}, 1153 | {file = "Pillow-9.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ede5af4a2702444a832a800b8eb7f0a7a1c0eed55b644642e049c98d589e5092"}, 1154 | {file = "Pillow-9.0.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:b5b3f092fe345c03bca1e0b687dfbb39364b21ebb8ba90e3fa707374b7915204"}, 1155 | {file = "Pillow-9.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:335ace1a22325395c4ea88e00ba3dc89ca029bd66bd5a3c382d53e44f0ccd77e"}, 1156 | {file = "Pillow-9.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db6d9fac65bd08cea7f3540b899977c6dee9edad959fa4eaf305940d9cbd861c"}, 1157 | {file = "Pillow-9.0.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f154d173286a5d1863637a7dcd8c3437bb557520b01bddb0be0258dcb72696b5"}, 1158 | {file = "Pillow-9.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d4b1341ac07ae07eb2cc682f459bec932a380c3b122f5540432d8977e64eae"}, 1159 | {file = "Pillow-9.0.1-cp38-cp38-win32.whl", hash = "sha256:effb7749713d5317478bb3acb3f81d9d7c7f86726d41c1facca068a04cf5bb4c"}, 1160 | {file = "Pillow-9.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:7f7609a718b177bf171ac93cea9fd2ddc0e03e84d8fa4e887bdfc39671d46b00"}, 1161 | {file = "Pillow-9.0.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:80ca33961ced9c63358056bd08403ff866512038883e74f3a4bf88ad3eb66838"}, 1162 | {file = "Pillow-9.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1c3c33ac69cf059bbb9d1a71eeaba76781b450bc307e2291f8a4764d779a6b28"}, 1163 | {file = "Pillow-9.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12875d118f21cf35604176872447cdb57b07126750a33748bac15e77f90f1f9c"}, 1164 | {file = "Pillow-9.0.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:514ceac913076feefbeaf89771fd6febde78b0c4c1b23aaeab082c41c694e81b"}, 1165 | {file = "Pillow-9.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3c5c79ab7dfce6d88f1ba639b77e77a17ea33a01b07b99840d6ed08031cb2a7"}, 1166 | {file = "Pillow-9.0.1-cp39-cp39-win32.whl", hash = "sha256:718856856ba31f14f13ba885ff13874be7fefc53984d2832458f12c38205f7f7"}, 1167 | {file = "Pillow-9.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:f25ed6e28ddf50de7e7ea99d7a976d6a9c415f03adcaac9c41ff6ff41b6d86ac"}, 1168 | {file = "Pillow-9.0.1-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:011233e0c42a4a7836498e98c1acf5e744c96a67dd5032a6f666cc1fb97eab97"}, 1169 | {file = "Pillow-9.0.1-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:253e8a302a96df6927310a9d44e6103055e8fb96a6822f8b7f514bb7ef77de56"}, 1170 | {file = "Pillow-9.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6295f6763749b89c994fcb6d8a7f7ce03c3992e695f89f00b741b4580b199b7e"}, 1171 | {file = "Pillow-9.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:a9f44cd7e162ac6191491d7249cceb02b8116b0f7e847ee33f739d7cb1ea1f70"}, 1172 | {file = "Pillow-9.0.1.tar.gz", hash = "sha256:6c8bc8238a7dfdaf7a75f5ec5a663f4173f8c367e5a39f87e720495e1eed75fa"}, 1173 | ] 1174 | platformdirs = [ 1175 | {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"}, 1176 | {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"}, 1177 | ] 1178 | pluggy = [ 1179 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 1180 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 1181 | ] 1182 | protobuf = [ 1183 | {file = "protobuf-3.19.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f51d5a9f137f7a2cec2d326a74b6e3fc79d635d69ffe1b036d39fc7d75430d37"}, 1184 | {file = "protobuf-3.19.4-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:09297b7972da685ce269ec52af761743714996b4381c085205914c41fcab59fb"}, 1185 | {file = "protobuf-3.19.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:072fbc78d705d3edc7ccac58a62c4c8e0cec856987da7df8aca86e647be4e35c"}, 1186 | {file = "protobuf-3.19.4-cp310-cp310-win32.whl", hash = "sha256:7bb03bc2873a2842e5ebb4801f5c7ff1bfbdf426f85d0172f7644fcda0671ae0"}, 1187 | {file = "protobuf-3.19.4-cp310-cp310-win_amd64.whl", hash = "sha256:f358aa33e03b7a84e0d91270a4d4d8f5df6921abe99a377828839e8ed0c04e07"}, 1188 | {file = "protobuf-3.19.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1c91ef4110fdd2c590effb5dca8fdbdcb3bf563eece99287019c4204f53d81a4"}, 1189 | {file = "protobuf-3.19.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c438268eebb8cf039552897d78f402d734a404f1360592fef55297285f7f953f"}, 1190 | {file = "protobuf-3.19.4-cp36-cp36m-win32.whl", hash = "sha256:835a9c949dc193953c319603b2961c5c8f4327957fe23d914ca80d982665e8ee"}, 1191 | {file = "protobuf-3.19.4-cp36-cp36m-win_amd64.whl", hash = "sha256:4276cdec4447bd5015453e41bdc0c0c1234eda08420b7c9a18b8d647add51e4b"}, 1192 | {file = "protobuf-3.19.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6cbc312be5e71869d9d5ea25147cdf652a6781cf4d906497ca7690b7b9b5df13"}, 1193 | {file = "protobuf-3.19.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:54a1473077f3b616779ce31f477351a45b4fef8c9fd7892d6d87e287a38df368"}, 1194 | {file = "protobuf-3.19.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:435bb78b37fc386f9275a7035fe4fb1364484e38980d0dd91bc834a02c5ec909"}, 1195 | {file = "protobuf-3.19.4-cp37-cp37m-win32.whl", hash = "sha256:16f519de1313f1b7139ad70772e7db515b1420d208cb16c6d7858ea989fc64a9"}, 1196 | {file = "protobuf-3.19.4-cp37-cp37m-win_amd64.whl", hash = "sha256:cdc076c03381f5c1d9bb1abdcc5503d9ca8b53cf0a9d31a9f6754ec9e6c8af0f"}, 1197 | {file = "protobuf-3.19.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:69da7d39e39942bd52848438462674c463e23963a1fdaa84d88df7fbd7e749b2"}, 1198 | {file = "protobuf-3.19.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:48ed3877fa43e22bcacc852ca76d4775741f9709dd9575881a373bd3e85e54b2"}, 1199 | {file = "protobuf-3.19.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd95d1dfb9c4f4563e6093a9aa19d9c186bf98fa54da5252531cc0d3a07977e7"}, 1200 | {file = "protobuf-3.19.4-cp38-cp38-win32.whl", hash = "sha256:b38057450a0c566cbd04890a40edf916db890f2818e8682221611d78dc32ae26"}, 1201 | {file = "protobuf-3.19.4-cp38-cp38-win_amd64.whl", hash = "sha256:7ca7da9c339ca8890d66958f5462beabd611eca6c958691a8fe6eccbd1eb0c6e"}, 1202 | {file = "protobuf-3.19.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:36cecbabbda242915529b8ff364f2263cd4de7c46bbe361418b5ed859677ba58"}, 1203 | {file = "protobuf-3.19.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:c1068287025f8ea025103e37d62ffd63fec8e9e636246b89c341aeda8a67c934"}, 1204 | {file = "protobuf-3.19.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96bd766831596d6014ca88d86dc8fe0fb2e428c0b02432fd9db3943202bf8c5e"}, 1205 | {file = "protobuf-3.19.4-cp39-cp39-win32.whl", hash = "sha256:84123274d982b9e248a143dadd1b9815049f4477dc783bf84efe6250eb4b836a"}, 1206 | {file = "protobuf-3.19.4-cp39-cp39-win_amd64.whl", hash = "sha256:3112b58aac3bac9c8be2b60a9daf6b558ca3f7681c130dcdd788ade7c9ffbdca"}, 1207 | {file = "protobuf-3.19.4-py2.py3-none-any.whl", hash = "sha256:8961c3a78ebfcd000920c9060a262f082f29838682b1f7201889300c1fbe0616"}, 1208 | {file = "protobuf-3.19.4.tar.gz", hash = "sha256:9df0c10adf3e83015ced42a9a7bd64e13d06c4cf45c340d2c63020ea04499d0a"}, 1209 | ] 1210 | py = [ 1211 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 1212 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 1213 | ] 1214 | pyasn1 = [ 1215 | {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"}, 1216 | {file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"}, 1217 | {file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"}, 1218 | {file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"}, 1219 | {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, 1220 | {file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"}, 1221 | {file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"}, 1222 | {file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"}, 1223 | {file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"}, 1224 | {file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"}, 1225 | {file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"}, 1226 | {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"}, 1227 | {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, 1228 | ] 1229 | pyasn1-modules = [ 1230 | {file = "pyasn1-modules-0.2.8.tar.gz", hash = "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e"}, 1231 | {file = "pyasn1_modules-0.2.8-py2.4.egg", hash = "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199"}, 1232 | {file = "pyasn1_modules-0.2.8-py2.5.egg", hash = "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405"}, 1233 | {file = "pyasn1_modules-0.2.8-py2.6.egg", hash = "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb"}, 1234 | {file = "pyasn1_modules-0.2.8-py2.7.egg", hash = "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8"}, 1235 | {file = "pyasn1_modules-0.2.8-py2.py3-none-any.whl", hash = "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"}, 1236 | {file = "pyasn1_modules-0.2.8-py3.1.egg", hash = "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d"}, 1237 | {file = "pyasn1_modules-0.2.8-py3.2.egg", hash = "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45"}, 1238 | {file = "pyasn1_modules-0.2.8-py3.3.egg", hash = "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4"}, 1239 | {file = "pyasn1_modules-0.2.8-py3.4.egg", hash = "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811"}, 1240 | {file = "pyasn1_modules-0.2.8-py3.5.egg", hash = "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed"}, 1241 | {file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"}, 1242 | {file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"}, 1243 | ] 1244 | pydash = [ 1245 | {file = "pydash-5.1.0-py3-none-any.whl", hash = "sha256:ced4fedb163eb07fbee376e474bca74029eb9fab215614449fe13164f71dd9e3"}, 1246 | {file = "pydash-5.1.0.tar.gz", hash = "sha256:1b2b050ac1bae049cd07f5920b14fabbe52638f485d9ada1eb115a9eebff6835"}, 1247 | ] 1248 | pyparsing = [ 1249 | {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, 1250 | {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, 1251 | ] 1252 | pyrsistent = [ 1253 | {file = "pyrsistent-0.18.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:df46c854f490f81210870e509818b729db4488e1f30f2a1ce1698b2295a878d1"}, 1254 | {file = "pyrsistent-0.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d45866ececf4a5fff8742c25722da6d4c9e180daa7b405dc0a2a2790d668c26"}, 1255 | {file = "pyrsistent-0.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ed6784ceac462a7d6fcb7e9b663e93b9a6fb373b7f43594f9ff68875788e01e"}, 1256 | {file = "pyrsistent-0.18.1-cp310-cp310-win32.whl", hash = "sha256:e4f3149fd5eb9b285d6bfb54d2e5173f6a116fe19172686797c056672689daf6"}, 1257 | {file = "pyrsistent-0.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:636ce2dc235046ccd3d8c56a7ad54e99d5c1cd0ef07d9ae847306c91d11b5fec"}, 1258 | {file = "pyrsistent-0.18.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e92a52c166426efbe0d1ec1332ee9119b6d32fc1f0bbfd55d5c1088070e7fc1b"}, 1259 | {file = "pyrsistent-0.18.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7a096646eab884bf8bed965bad63ea327e0d0c38989fc83c5ea7b8a87037bfc"}, 1260 | {file = "pyrsistent-0.18.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cdfd2c361b8a8e5d9499b9082b501c452ade8bbf42aef97ea04854f4a3f43b22"}, 1261 | {file = "pyrsistent-0.18.1-cp37-cp37m-win32.whl", hash = "sha256:7ec335fc998faa4febe75cc5268a9eac0478b3f681602c1f27befaf2a1abe1d8"}, 1262 | {file = "pyrsistent-0.18.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6455fc599df93d1f60e1c5c4fe471499f08d190d57eca040c0ea182301321286"}, 1263 | {file = "pyrsistent-0.18.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fd8da6d0124efa2f67d86fa70c851022f87c98e205f0594e1fae044e7119a5a6"}, 1264 | {file = "pyrsistent-0.18.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bfe2388663fd18bd8ce7db2c91c7400bf3e1a9e8bd7d63bf7e77d39051b85ec"}, 1265 | {file = "pyrsistent-0.18.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e3e1fcc45199df76053026a51cc59ab2ea3fc7c094c6627e93b7b44cdae2c8c"}, 1266 | {file = "pyrsistent-0.18.1-cp38-cp38-win32.whl", hash = "sha256:b568f35ad53a7b07ed9b1b2bae09eb15cdd671a5ba5d2c66caee40dbf91c68ca"}, 1267 | {file = "pyrsistent-0.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:d1b96547410f76078eaf66d282ddca2e4baae8964364abb4f4dcdde855cd123a"}, 1268 | {file = "pyrsistent-0.18.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f87cc2863ef33c709e237d4b5f4502a62a00fab450c9e020892e8e2ede5847f5"}, 1269 | {file = "pyrsistent-0.18.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bc66318fb7ee012071b2792024564973ecc80e9522842eb4e17743604b5e045"}, 1270 | {file = "pyrsistent-0.18.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:914474c9f1d93080338ace89cb2acee74f4f666fb0424896fcfb8d86058bf17c"}, 1271 | {file = "pyrsistent-0.18.1-cp39-cp39-win32.whl", hash = "sha256:1b34eedd6812bf4d33814fca1b66005805d3640ce53140ab8bbb1e2651b0d9bc"}, 1272 | {file = "pyrsistent-0.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:e24a828f57e0c337c8d8bb9f6b12f09dfdf0273da25fda9e314f0b684b415a07"}, 1273 | {file = "pyrsistent-0.18.1.tar.gz", hash = "sha256:d4d61f8b993a7255ba714df3aca52700f8125289f84f704cf80916517c46eb96"}, 1274 | ] 1275 | pytest = [ 1276 | {file = "pytest-7.1.1-py3-none-any.whl", hash = "sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea"}, 1277 | {file = "pytest-7.1.1.tar.gz", hash = "sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63"}, 1278 | ] 1279 | pytest-cov = [ 1280 | {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, 1281 | {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, 1282 | ] 1283 | python-dateutil = [ 1284 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 1285 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 1286 | ] 1287 | python-json-logger = [ 1288 | {file = "python-json-logger-2.0.2.tar.gz", hash = "sha256:202a4f29901a4b8002a6d1b958407eeb2dd1d83c18b18b816f5b64476dde9096"}, 1289 | {file = "python_json_logger-2.0.2-py3-none-any.whl", hash = "sha256:99310d148f054e858cd5f4258794ed6777e7ad2c3fd7e1c1b527f1cba4d08420"}, 1290 | ] 1291 | pytz = [ 1292 | {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, 1293 | {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, 1294 | ] 1295 | requests = [ 1296 | {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, 1297 | {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, 1298 | ] 1299 | rfc3339-validator = [ 1300 | {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, 1301 | {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, 1302 | ] 1303 | rsa = [ 1304 | {file = "rsa-4.8-py3-none-any.whl", hash = "sha256:95c5d300c4e879ee69708c428ba566c59478fd653cc3a22243eeb8ed846950bb"}, 1305 | {file = "rsa-4.8.tar.gz", hash = "sha256:5c6bd9dc7a543b7fe4304a631f8a8a3b674e2bbfc49c2ae96200cdbe55df6b17"}, 1306 | ] 1307 | setuptools-scm = [ 1308 | {file = "setuptools_scm-6.4.2-py3-none-any.whl", hash = "sha256:acea13255093849de7ccb11af9e1fb8bde7067783450cee9ef7a93139bddf6d4"}, 1309 | {file = "setuptools_scm-6.4.2.tar.gz", hash = "sha256:6833ac65c6ed9711a4d5d2266f8024cfa07c533a0e55f4c12f6eff280a5a9e30"}, 1310 | ] 1311 | six = [ 1312 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 1313 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 1314 | ] 1315 | strict-rfc3339 = [ 1316 | {file = "strict-rfc3339-0.7.tar.gz", hash = "sha256:5cad17bedfc3af57b399db0fed32771f18fc54bbd917e85546088607ac5e1277"}, 1317 | ] 1318 | tabulate = [ 1319 | {file = "tabulate-0.8.9-py3-none-any.whl", hash = "sha256:d7c013fe7abbc5e491394e10fa845f8f32fe54f8dc60c6622c6cf482d25d47e4"}, 1320 | {file = "tabulate-0.8.9.tar.gz", hash = "sha256:eb1d13f25760052e8931f2ef80aaf6045a6cceb47514db8beab24cded16f13a7"}, 1321 | ] 1322 | takethetime = [ 1323 | {file = "TakeTheTime-0.3.1.tar.gz", hash = "sha256:dbe30453a1b596a38f9e2e3fa8e1adc5af2dbf646ca0837ad5c2059a16fe2ff9"}, 1324 | ] 1325 | timeslot = [ 1326 | {file = "timeslot-0.1.2-py3-none-any.whl", hash = "sha256:2f8efaec7b0a4c1e56a92ec05533219332dd9d8b577539077664c233996911b5"}, 1327 | {file = "timeslot-0.1.2.tar.gz", hash = "sha256:a2ac998657e3f3b9ca928757b4906add2c05390c5fc14ed792bb9028d08547b1"}, 1328 | ] 1329 | toml = [ 1330 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 1331 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 1332 | ] 1333 | tomli = [ 1334 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 1335 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 1336 | ] 1337 | tomlkit = [ 1338 | {file = "tomlkit-0.10.0-py3-none-any.whl", hash = "sha256:cac4aeaff42f18fef6e07831c2c2689a51df76cf2ede07a6a4fa5fcb83558870"}, 1339 | {file = "tomlkit-0.10.0.tar.gz", hash = "sha256:d99946c6aed3387c98b89d91fb9edff8f901bf9255901081266a84fb5604adcd"}, 1340 | ] 1341 | typing-extensions = [ 1342 | {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, 1343 | {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, 1344 | ] 1345 | uritemplate = [ 1346 | {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, 1347 | {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, 1348 | ] 1349 | urllib3 = [ 1350 | {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, 1351 | {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, 1352 | ] 1353 | zipp = [ 1354 | {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"}, 1355 | {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"}, 1356 | ] 1357 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "aw-research" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Erik Bjäreholt "] 6 | 7 | [tool.poetry.scripts] 8 | aw-research = "aw_research.main:main" 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.8" 12 | aw-core = "^0.5" 13 | aw-client = "^0.5" 14 | numpy = "*" 15 | matplotlib = "*" 16 | pandas = "*" 17 | joblib = "*" 18 | pydash = "*" 19 | iso8601 = "*" 20 | pytz = "*" # really needed? 21 | toml = "*" 22 | google-api-python-client = "*" 23 | tabulate = "*" 24 | 25 | [tool.poetry.dev-dependencies] 26 | mypy = "*" 27 | pytest = "*" 28 | pytest-cov = "*" 29 | black = "*" 30 | 31 | [build-system] 32 | requires = ["poetry>=0.12"] 33 | build-backend = "poetry.masonry.api" 34 | -------------------------------------------------------------------------------- /queries/aw-development.awq: -------------------------------------------------------------------------------- 1 | window_events = query_bucket(find_bucket("aw-watcher-window_")); 2 | afk_events = query_bucket(find_bucket("aw-watcher-afk_")); 3 | window_events_active = filter_period_intersect(window_events, filter_keyvals(afk_events, "status", ["not-afk"])); 4 | 5 | cmt = "We should really add support for comments"; 6 | 7 | events = filter_keyvals_regex(window_events_active, "title", "[Aa]ctivity[Ww]atch|aw-"); 8 | cmt = 'events = merge_events_by_keys(events, "app", "title")'; 9 | cmt = 'events = sort_by_duration(events)'; 10 | RETURN = events; 11 | -------------------------------------------------------------------------------- /tests/test_all.py: -------------------------------------------------------------------------------- 1 | from aw_research import * 2 | 3 | 4 | def test_imports(): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/test_redact.py: -------------------------------------------------------------------------------- 1 | from aw_core.models import Event 2 | 3 | from aw_research.redact import redact_words 4 | 5 | 6 | def test_redact_word(): 7 | e = Event(data={"label": "Sensitive stuff", "desc": "Lorem ipsum..."}) 8 | e = redact_words([e], "(Sensitive stuff)")[0] 9 | assert "sensitive" not in e.data["label"] 10 | assert "REDACTED" in e.data["label"] 11 | assert "REDACTED" in e.data["desc"] 12 | 13 | e = redact_words([e], "(sensitive)", ignore_case=True)[0] 14 | assert "sensitive" not in e.data["label"] 15 | --------------------------------------------------------------------------------