├── .github └── workflows │ └── build.yml ├── .gitignore ├── Makefile ├── README.md ├── chatalysis ├── __init__.py ├── __main__.py ├── calendar_heatmap.py ├── load.py ├── main.py ├── models.py ├── py.typed └── util.py ├── data └── private │ └── .empty ├── poetry.lock └── pyproject.toml /.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 | tests: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ['3.9'] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | with: 21 | submodules: 'recursive' 22 | fetch-depth: 0 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v1 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install 29 | run: | 30 | pip install poetry 31 | poetry install 32 | - name: Run tests 33 | run: | 34 | make test 35 | bash <(curl -s https://codecov.io/bash) 36 | 37 | typecheck: 38 | runs-on: ubuntu-latest 39 | 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | python-version: ['3.9'] 44 | 45 | steps: 46 | - uses: actions/checkout@v2 47 | with: 48 | submodules: 'recursive' 49 | 50 | - name: Set up Python 51 | uses: actions/setup-python@v1 52 | with: 53 | python-version: ${{ matrix.python-version }} 54 | 55 | - name: Install 56 | run: | 57 | pip install poetry 58 | poetry install 59 | 60 | - name: Run typecheck 61 | run: | 62 | make typecheck 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .*cache 3 | data/private 4 | *.egg-info 5 | *.csv 6 | *coverage* 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | main: 2 | poetry run python3 main.py 3 | 4 | test: 5 | poetry run python3 -m pytest --cov=chatalysis --cov-report=xml 6 | 7 | typecheck: 8 | poetry run mypy chatalysis/*.py --ignore-missing-imports 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Chatalysis 2 | ========== 3 | 4 | [![Build Status GitHub](https://github.com/ErikBjare/chatalysis/workflows/Build/badge.svg?branch=master)](https://github.com/ErikBjare/chatalysis/actions?query=branch%3Amaster) 5 | [![codecov](https://codecov.io/gh/ErikBjare/chatalysis/branch/master/graph/badge.svg?token=mG3sqsPL6Z)](https://codecov.io/gh/ErikBjare/chatalysis) 6 | 7 | Analyse chat conversations to figure out: 8 | 9 | - Who you are writing with, how much, and when. 10 | - Who contributes the most to the conversation (and who's just creeping). 11 | - Which messages have the most reacts. 12 | - Search all past messages by author or content. 13 | 14 | 15 | ## Usage 16 | 17 | 1. Download the information you want to analyze here: https://www.facebook.com/dyi/ 18 | - Note: Make sure to use JSON 19 | - Currently only supports: Messages 20 | 2. Extract the zip contents into `./data/private` 21 | 3. Install dependencies with `poetry install` or `pip install .` 22 | 3. `chatalysis --help` 23 | 24 | ``` 25 | $ chatalysis --help 26 | Usage: chatalysis [OPTIONS] COMMAND [ARGS]... 27 | 28 | Options: 29 | --help Show this message and exit. 30 | 31 | Commands: 32 | convos List all conversations (groups and 1-1s) 33 | creeps List creeping participants (who have minimal or no... 34 | daily Your messaging stats, by date 35 | messages List messages, filter by user or content. 36 | most-reacted List the most reacted messages 37 | people List all people 38 | top-writers List the top writers 39 | yearly Your messaging stats, by year 40 | ``` 41 | 42 | 43 | ## TODO 44 | 45 | - Support more datasources 46 | - Analyze which domains are most frequently linked. 47 | - Sentiment analysis 48 | - Try making metrics to analyze popularity/message/"alpha"/"signal" quality (average positive reacts per message?) 49 | -------------------------------------------------------------------------------- /chatalysis/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import main 2 | from .models import Message, Conversation 3 | -------------------------------------------------------------------------------- /chatalysis/__main__.py: -------------------------------------------------------------------------------- 1 | from chatalysis.main import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /chatalysis/calendar_heatmap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Based on: https://stackoverflow.com/a/32492179/965332 3 | """ 4 | 5 | import datetime as dt 6 | import matplotlib.pyplot as plt 7 | import numpy as np 8 | from typing import Dict, List, Tuple 9 | 10 | import click 11 | 12 | from .main import _load_all_messages 13 | from .util import _calendar 14 | 15 | 16 | @click.command() 17 | @click.argument("glob") 18 | def main(glob: str) -> None: 19 | data = _load_data(glob) 20 | if not data: 21 | raise Exception("No conversations matched glob") 22 | plot(data) 23 | 24 | 25 | def plot(data: Dict[dt.date, float]) -> None: 26 | fig, ax = plt.subplots(figsize=(6, 10)) 27 | 28 | dates = list(data.keys()) 29 | _calendar_heatmap(ax, dates, list(data.values())) 30 | plt.show() 31 | 32 | 33 | def _load_data(glob: str) -> Dict[dt.date, float]: 34 | msgs = _load_all_messages(glob) 35 | d = _calendar(msgs) 36 | # FIXME: the `k.year == 2018` thing is just set because the plotting doesn't 37 | # support crossing year-boundaries without weirdness. 38 | return {k: len(v) for k, v in d.items() if k.year == 2020} 39 | 40 | 41 | def _calendar_array( 42 | dates: List[dt.date], data: List[float] 43 | ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: 44 | yl, wl, dl = zip(*[d.isocalendar() for d in dates]) 45 | w: np.ndarray = np.array(wl) - min(wl) 46 | d: np.ndarray = np.array(dl) - 1 47 | wi = max(w) + 1 48 | 49 | # y = np.array(yl) - min(yl) 50 | # wi = max(y) * 53 + max(w) + 1 51 | 52 | calendar: np.ndarray = np.nan * np.zeros((wi, 7)) 53 | calendar[w, d] = data 54 | return w, d, calendar 55 | 56 | 57 | def _calendar_heatmap(ax: plt.Axes, dates: List[dt.date], data: List[float]): 58 | i, j, calendar = _calendar_array(dates, data) 59 | im = ax.imshow(calendar.T, interpolation="none", cmap="YlGn") 60 | _label_days(ax, dates, i, j, calendar) 61 | _label_months(ax, dates, i, j, calendar) 62 | ax.figure.colorbar(im) 63 | 64 | 65 | def _date_nth(n: int) -> str: 66 | if n == 1: 67 | return "1st" 68 | elif n == 2: 69 | return "2nd" 70 | elif n == 3: 71 | return "3rd" 72 | else: 73 | return f"{n}th" 74 | 75 | 76 | def _label_days(ax: plt.Axes, dates: List[dt.date], i, j, calendar) -> None: 77 | ni, nj = calendar.shape 78 | day_of_month = np.nan * np.zeros((ni, 7)) 79 | day_of_month[i, j] = [d.day for d in dates] 80 | 81 | for (i, j), day in np.ndenumerate(day_of_month): 82 | if np.isfinite(day): 83 | ax.text( 84 | i, 85 | j, 86 | f"{_date_nth(int(day))}\n{int(calendar[i, j])}", 87 | ha="center", 88 | va="center", 89 | ) 90 | 91 | ax.set(yticks=np.arange(7), yticklabels=["M", "T", "W", "R", "F", "S", "S"]) 92 | ax.yaxis.tick_left() 93 | 94 | 95 | def _label_months(ax: plt.Axes, dates: List[dt.date], i, j, calendar) -> None: 96 | month_labels = np.array( 97 | [ 98 | "Jan", 99 | "Feb", 100 | "Mar", 101 | "Apr", 102 | "May", 103 | "Jun", 104 | "Jul", 105 | "Aug", 106 | "Sep", 107 | "Oct", 108 | "Nov", 109 | "Dec", 110 | ] 111 | ) 112 | months = np.array([d.year * 100 + d.month for d in dates]) 113 | uniq_months = sorted(set(months)) 114 | ticks = [i[months == m].mean() for m in uniq_months] 115 | labels = [ 116 | (f"{m // 100}\n" if (m % 100) == 1 else "") + month_labels[(m % 100) - 1] 117 | for m in uniq_months 118 | ] 119 | ax.set(xticks=ticks) 120 | ax.set_xticklabels(labels, rotation=90) 121 | 122 | 123 | if __name__ == "__main__": 124 | main() 125 | -------------------------------------------------------------------------------- /chatalysis/load.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from pathlib import Path 4 | from datetime import datetime 5 | from typing import Optional 6 | 7 | from joblib import Memory 8 | 9 | from .models import Message, Conversation 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | cache_location = "./.message_cache" 14 | memory = Memory(cache_location, verbose=0) 15 | 16 | # TODO: Remove this constant, make configurable 17 | ME = "Erik Bjäreholt" 18 | 19 | 20 | def _get_all_conv_dirs(): 21 | msgdir = Path("data/private/messages/inbox") 22 | return [path.parent for path in msgdir.glob("*/message_1.json")] 23 | 24 | 25 | def _load_convo(convdir: Path) -> Conversation: 26 | chatfiles = convdir.glob("message_*.json") 27 | convo = None 28 | for file in chatfiles: 29 | if convo is None: 30 | convo = _parse_chatfile(file) 31 | else: 32 | convo = convo.merge(_parse_chatfile(file)) 33 | assert convo is not None 34 | return convo 35 | 36 | 37 | def _load_convos(glob="*"): 38 | logger.info("Loading conversations...") 39 | convos = [_load_convo(convdir) for convdir in _get_all_conv_dirs()] 40 | if glob != "*": 41 | convos = [convo for convo in convos if glob.lower() in convo.title.lower()] 42 | return convos 43 | 44 | 45 | def _get_all_chat_files(glob="*"): 46 | msgdir = Path("data/private/messages/inbox") 47 | return sorted( 48 | [ 49 | chatfile 50 | for convdir in _get_all_conv_dirs() 51 | for chatfile in msgdir.glob(f"{glob}/message*.json") 52 | ] 53 | ) 54 | 55 | 56 | def _list_all_chats(): 57 | conversations = _get_all_chat_files() 58 | for chat in conversations: 59 | with open(chat) as f: 60 | data = json.load(f) 61 | 62 | 63 | def _load_all_messages(glob: str = "*") -> list[Message]: 64 | messages = [msg for convo in _load_convos(glob) for msg in convo.messages] 65 | logger.info(f"Loaded {len(messages)} messages") 66 | return messages 67 | 68 | 69 | def _parse_message(msg: dict, is_groupchat: bool, title: str) -> Optional[Message]: 70 | _type = msg.pop("type") 71 | if _type == "Subscribe": 72 | # We don't care about 'X added Y to the group' 73 | return None 74 | elif _type == "Generic": 75 | if "content" not in msg: 76 | return None 77 | else: 78 | text = msg.pop("content").encode("latin1").decode("utf8") 79 | elif _type == "Share": 80 | if "share" in msg: 81 | share = msg.pop("share", None) 82 | if share: 83 | text = share["link"] 84 | else: 85 | logger.warning("Share message without share field") 86 | else: 87 | logger.info(f"Skipping non-text message with type {_type}: {msg}") 88 | 89 | is_unsent = msg.pop("is_unsent", None) 90 | if is_unsent: 91 | print(f"is_unsent: {is_unsent}") 92 | 93 | # the `.encode('latin1').decode('utf8')` hack is needed due to https://stackoverflow.com/a/50011987/965332 94 | sender = msg.pop("sender_name").encode("latin1").decode("utf8") 95 | reacts: list[dict] = msg.pop("reactions", []) 96 | for react in reacts: 97 | react["reaction"] = react["reaction"].encode("latin1").decode("utf8") 98 | react["actor"] = react["actor"].encode("latin1").decode("utf8") 99 | 100 | receiver = ME if not is_groupchat and sender != ME else title 101 | date = datetime.fromtimestamp(msg.pop("timestamp_ms") / 1000) 102 | 103 | # find remaining unused keys in msg 104 | unused_keys = set(msg.keys()) - {"is_unsent"} 105 | for key in unused_keys: 106 | logger.info(f"Skipping unknown key: {key}") 107 | 108 | data = {"groupchat": is_groupchat} 109 | return Message( 110 | sender, 111 | receiver, 112 | date, 113 | text, 114 | reactions=reacts, 115 | data=data, 116 | ) 117 | 118 | 119 | def test_parse_message_text(): 120 | name = "Erik Bjäreholt".encode("utf-8").decode("latin1") 121 | content = "Hello" 122 | msg = { 123 | "type": "Generic", 124 | "content": content, 125 | "timestamp_ms": 1568010580000, 126 | "sender_name": name, 127 | "reactions": [], 128 | } 129 | assert _parse_message(msg, False, "") is not None 130 | 131 | 132 | def test_parse_message_share(): 133 | name = "Erik Bjäreholt".encode("utf-8").decode("latin1") 134 | url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 135 | msg = { 136 | "type": "Share", 137 | "share": { 138 | "link": url, 139 | }, 140 | "timestamp_ms": 1568010580000, 141 | "sender_name": name, 142 | } 143 | resmsg = _parse_message(msg, False, "") 144 | assert resmsg is not None 145 | assert resmsg.content == url 146 | 147 | 148 | @memory.cache 149 | def _parse_chatfile(filename: str) -> Conversation: 150 | # FIXME: This should open all `message_*.json` files and merge into a single convo 151 | messages = [] 152 | with open(filename) as f: 153 | data = json.load(f) 154 | title = data["title"].encode("latin1").decode("utf8") 155 | participants: list[str] = [ 156 | p["name"].encode("latin1").decode("utf8") for p in data["participants"] 157 | ] 158 | # print(participants) 159 | 160 | # Can be one of at least: Regular, RegularGroup 161 | thread_type = data.pop("thread_type") 162 | is_groupchat = thread_type == "RegularGroup" 163 | 164 | for msg in data["messages"]: 165 | message = _parse_message(msg, is_groupchat, title) 166 | if message is not None: 167 | messages.append(message) 168 | 169 | return Conversation( 170 | title=title, 171 | participants=participants, 172 | messages=messages, 173 | data={"groupchat": is_groupchat}, 174 | ) 175 | -------------------------------------------------------------------------------- /chatalysis/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import textwrap 3 | 4 | from collections import defaultdict 5 | from typing import List, Any, Tuple, Dict 6 | from itertools import groupby 7 | 8 | import click 9 | from tabulate import tabulate 10 | 11 | from .models import Message, Writerstats 12 | from .util import ( 13 | _calculate_streak, 14 | _format_emojicount, 15 | _most_used_emoji, 16 | _convo_participants_key_undir, 17 | _filter_author, 18 | ) 19 | from .load import _load_all_messages, _load_convos 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | @click.group() 25 | def main(): 26 | # memory.clear() 27 | logging.basicConfig(level=logging.DEBUG) 28 | pass 29 | 30 | 31 | @main.command() 32 | @click.argument("glob", default="*") 33 | @click.option("--user") 34 | def daily(glob: str, user: str = None) -> None: 35 | """Your messaging stats, by date""" 36 | msgs = _load_all_messages(glob) 37 | if user: 38 | msgs = _filter_author(msgs, user) 39 | _daily_messaging_stats(msgs) 40 | 41 | 42 | @main.command() 43 | @click.argument("glob", default="*") 44 | @click.option("--user") 45 | def yearly(glob: str, user: str = None) -> None: 46 | """Your messaging stats, by year""" 47 | msgs = _load_all_messages(glob) 48 | if user: 49 | msgs = _filter_author(msgs, user) 50 | _yearly_messaging_stats(msgs) 51 | 52 | 53 | @main.command() 54 | @click.argument("glob", default="*") 55 | def top_writers(glob: str) -> None: 56 | """List the top writers""" 57 | msgs = _load_all_messages(glob) 58 | _top_writers(msgs) 59 | 60 | 61 | @main.command() 62 | @click.option("--user") 63 | @click.option("--contains") 64 | def messages(user: str = None, contains: str = None) -> None: 65 | """List messages, filter by user or content.""" 66 | msgs = _load_all_messages() 67 | if user: 68 | msgs = [msg for msg in msgs if user.lower() in msg.from_name.lower()] 69 | if contains: 70 | msgs = [msg for msg in msgs if contains.lower() in msg.content.lower()] 71 | msgs = sorted(msgs, key=lambda m: m.timestamp) 72 | for msg in msgs: 73 | msg.print() 74 | 75 | 76 | @main.command() 77 | def people() -> None: 78 | """List all people""" 79 | msgs = _load_all_messages() 80 | _people_stats(msgs) 81 | 82 | 83 | @main.command() 84 | @click.argument("glob", default="*") 85 | def convos(glob: str) -> None: 86 | """List all conversations (groups and 1-1s)""" 87 | convos = _load_convos(glob) 88 | 89 | data = [] 90 | wrapper = textwrap.TextWrapper(max_lines=1, width=30, placeholder="...") 91 | for convo in convos: 92 | data.append( 93 | (wrapper.fill(convo.title), len(convo.participants), len(convo.messages)) 94 | ) 95 | data = sorted(data, key=lambda t: t[2]) 96 | print(tabulate(data, headers=["name", "members", "messages"])) 97 | 98 | 99 | @main.command() 100 | @click.argument("glob", default="*") 101 | def most_reacted(glob: str) -> None: 102 | """List the most reacted messages""" 103 | msgs = _load_all_messages(glob) 104 | _most_reacted_msgs(msgs) 105 | 106 | 107 | @main.command() 108 | @click.argument("glob", default="*") 109 | def creeps(glob: str) -> None: 110 | """ 111 | List creeping participants (who have minimal or no engagement) 112 | 113 | Note: this is perhaps easier using same output as from top-writers, but taking the bottom instead 114 | """ 115 | convos = _load_convos(glob) 116 | 117 | for convo in convos: 118 | if not convo.data["groupchat"]: 119 | continue 120 | 121 | messages_by_user: dict[str, int] = defaultdict(int) 122 | reacts_by_user: dict[str, int] = defaultdict(int) 123 | for message in convo.messages: 124 | messages_by_user[message.from_name] += 1 125 | for react in message.reactions: 126 | actor = react["actor"] 127 | reacts_by_user[actor] += 1 128 | 129 | fullcreeps = set(convo.participants) - ( 130 | set(messages_by_user.keys()) | set(reacts_by_user.keys()) 131 | ) 132 | 133 | # includes participants who've left the chat 134 | all_participants = set(convo.participants) | set(messages_by_user.keys()) 135 | print(f"# {convo.title}\n") 136 | stats = [ 137 | (part, messages_by_user[part], reacts_by_user[part]) 138 | for part in all_participants 139 | ] 140 | stats = list(reversed(sorted(stats, key=lambda t: (t[1], t[2])))) 141 | print( 142 | tabulate( 143 | stats, 144 | headers=["name", "messages", "reacts"], 145 | ) 146 | ) 147 | 148 | print("\nNo engagement from: " + ", ".join(sorted(fullcreeps))) 149 | print() 150 | 151 | 152 | def _most_reacted_msgs(msgs): 153 | msgs = filter(lambda m: m.reactions, msgs) 154 | msgs = sorted(msgs, key=lambda m: -len(m.reactions)) 155 | for msg in msgs[:30]: 156 | msg.print() 157 | 158 | 159 | def _yearly_messaging_stats(msgs: list[Message]): 160 | print(f"All-time messages sent: {len(msgs)}") 161 | 162 | msgs_by_date = defaultdict(list) 163 | for msg in msgs: 164 | msgs_by_date[msg.timestamp.year].append(msg) 165 | 166 | rows = [] 167 | for year, msgs in sorted(msgs_by_date.items()): 168 | if not msgs: 169 | continue 170 | rows.append( 171 | ( 172 | year, 173 | len(msgs), 174 | sum(len(m.content.split(" ")) for m in msgs), # words 175 | sum(len(m.content) for m in msgs), # chars 176 | ) 177 | ) 178 | print(tabulate(rows, headers=["year", "# msgs", "words", "chars"])) 179 | 180 | 181 | def _daily_messaging_stats(msgs: list[Message]): 182 | print(f"All-time messages sent: {len(msgs)}") 183 | 184 | msgs_by_date = defaultdict(list) 185 | for msg in msgs: 186 | msgs_by_date[msg.timestamp.date()].append(msg) 187 | 188 | rows = [] 189 | for d, msgs in sorted(msgs_by_date.items()): 190 | if not msgs: 191 | continue 192 | rows.append( 193 | ( 194 | d, 195 | len(msgs), 196 | sum(len(m.content.split(" ")) for m in msgs), # words 197 | sum(len(m.content) for m in msgs), # chars 198 | ) 199 | ) 200 | print(tabulate(rows, headers=["year", "# msgs", "words", "chars"])) 201 | 202 | 203 | def _writerstats(msgs: list[Message]) -> dict[str, Writerstats]: 204 | writerstats: dict[str, Writerstats] = defaultdict(lambda: Writerstats()) 205 | for msg in msgs: 206 | # if msg.data["groupchat"]: 207 | # continue 208 | s = writerstats[msg.from_name] 209 | s.days |= {msg.timestamp.date()} 210 | s.msgs += 1 211 | s.words += len(msg.content.split(" ")) 212 | for react in msg.reactions: 213 | # TODO: Save which reacts the writer used (with Counter?) 214 | s.reacts_recv += 1 # [react] 215 | writerstats[react["actor"]].reacts_sent += 1 # [react] 216 | 217 | return writerstats 218 | 219 | 220 | def _top_writers(msgs: list[Message]): 221 | writerstats = _writerstats(msgs) 222 | writerstats = dict( 223 | sorted(writerstats.items(), key=lambda kv: kv[1].msgs, reverse=True) 224 | ) 225 | 226 | wrapper = textwrap.TextWrapper(max_lines=1, width=30, placeholder="...") 227 | print( 228 | tabulate( 229 | [ 230 | ( 231 | wrapper.fill(writer), 232 | stats.msgs, 233 | len(stats.days), 234 | stats.words, 235 | stats.reacts_sent, 236 | stats.reacts_recv, 237 | round( 238 | 1000 * (stats.reacts_recv / stats.words) if stats.words else 0, 239 | ), 240 | ) 241 | for writer, stats in writerstats.items() 242 | ], 243 | headers=[ 244 | "name", 245 | "msgs", 246 | "days", 247 | "words", 248 | "reacts sent", 249 | "reacts recv", 250 | "reacts/1k words", 251 | ], 252 | ) 253 | ) 254 | 255 | 256 | def _people_stats(msgs: List[Message]) -> None: 257 | grouped = groupby( 258 | sorted(msgs, key=_convo_participants_key_undir), 259 | key=_convo_participants_key_undir, 260 | ) 261 | rows = [] 262 | for k, _v in grouped: 263 | v = list(_v) 264 | days = {m.timestamp.date() for m in v} 265 | rows.append( 266 | ( 267 | k[:40], 268 | len(v), 269 | len(days), 270 | _calculate_streak(days), 271 | _format_emojicount( 272 | dict(_most_used_emoji(m.content for m in v).most_common()[:5]) 273 | ), 274 | ) 275 | ) 276 | print(tabulate(rows, headers=["k", "days", "max streak", "most used emoji"])) 277 | 278 | 279 | def _connections(msgs: List[Message]) -> Dict[Tuple[str, str], int]: 280 | connections: Dict[Tuple[str, str], int] = defaultdict(int) 281 | for msg in msgs: 282 | if msg.data["groupchat"]: 283 | continue 284 | connections[(msg.from_name, msg.to_name)] += 1 285 | return connections 286 | 287 | 288 | @main.command() 289 | @click.option("--csv", "-c", is_flag=True) 290 | def connections(csv: bool) -> None: 291 | """ 292 | List all connections between interacting people, assigning weights as per the number of messages they have exchanged. 293 | """ 294 | # TODO: Also count reply-messages and immediately-following messages in groupchats 295 | msgs = _load_all_messages() 296 | connections = _connections(msgs) 297 | if csv: 298 | print(",".join(["from", "to", "count"])) 299 | for k, v in sorted(connections.items(), key=lambda kv: kv[1], reverse=True): 300 | print(",".join(map(str, k + (v,)))) 301 | else: 302 | print(tabulate(sorted(connections.items()), headers=["from", "to", "count"])) 303 | 304 | 305 | if __name__ == "__main__": 306 | main() 307 | -------------------------------------------------------------------------------- /chatalysis/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date 2 | from dataclasses import dataclass, field 3 | 4 | 5 | @dataclass 6 | class Message: 7 | from_name: str 8 | to_name: str 9 | timestamp: datetime 10 | content: str 11 | reactions: list[dict] = field(default_factory=list) 12 | data: dict = field(default_factory=dict) 13 | 14 | def print(self) -> None: 15 | from .util import _format_emojicount, _count_emoji 16 | 17 | emojicount_str = _format_emojicount( 18 | _count_emoji("".join(d["reaction"] for d in self.reactions)) 19 | ) 20 | content = self.content 21 | 22 | # start multiline messages on new line 23 | if content.count("\n") > 0: 24 | content = "\n " + "\n ".join(content.split("\n")) 25 | 26 | # wrap long lines correctly 27 | if content.count("\n") == 0: 28 | words = content.split(" ") 29 | content = " ".join(words[:20]) + " " + " ".join(words[20:]) 30 | print( 31 | f"{self.timestamp.isoformat()[:10]} | {self.from_name} -> {self.to_name}: {content}" 32 | + (" ({emojicount_str})" if emojicount_str else "") 33 | ) 34 | 35 | 36 | @dataclass 37 | class Conversation: 38 | title: str 39 | participants: list[str] 40 | messages: list[Message] 41 | data: dict 42 | 43 | def merge(self, c2: "Conversation") -> "Conversation": 44 | assert self.title == c2.title 45 | assert self.participants == c2.participants 46 | return Conversation( 47 | title=self.title, 48 | participants=self.participants, 49 | messages=sorted(self.messages + c2.messages, key=lambda m: m.timestamp), 50 | data=self.data, 51 | ) 52 | 53 | 54 | @dataclass 55 | class Writerstats: 56 | days: set[date] = field(default_factory=set) 57 | msgs: int = 0 58 | words: int = 0 59 | reacts_recv: int = 0 60 | reacts_sent: int = 0 61 | -------------------------------------------------------------------------------- /chatalysis/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErikBjare/chatalysis/1b33b39f5cf82f8782b562b1f36cd6eb04c209aa/chatalysis/py.typed -------------------------------------------------------------------------------- /chatalysis/util.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List, Dict, Iterator, Counter as TCounter 3 | from datetime import timedelta, date 4 | from itertools import groupby 5 | from collections import Counter, defaultdict 6 | 7 | from .models import Message 8 | 9 | # Idk how this works, but it does 10 | # https://stackoverflow.com/a/26740753/965332 11 | re_emoji = re.compile( 12 | "[\U00002600-\U000027BF]|[\U0001f300-\U0001f64F]|[\U0001f680-\U0001f6FF]" 13 | ) 14 | 15 | 16 | def _calculate_streak(days) -> int: 17 | days = sorted(days) 18 | last_day = None 19 | curr_streak = 0 20 | longest_streak = 0 21 | for day in days: 22 | if last_day: 23 | if last_day == day - timedelta(days=1): 24 | curr_streak += 1 25 | if curr_streak > longest_streak: 26 | longest_streak = curr_streak 27 | else: 28 | curr_streak = 0 29 | last_day = day 30 | return longest_streak 31 | 32 | 33 | def _count_emoji(txt: str) -> Dict[str, int]: 34 | return {k: len(list(v)) for k, v in groupby(sorted(re_emoji.findall(txt)))} 35 | 36 | 37 | def _format_emojicount(emojicount: Dict[str, int]): 38 | return ", ".join( 39 | f"{n}x {emoji}" 40 | for n, emoji in reversed(sorted((v, k) for k, v in emojicount.items())) 41 | ) 42 | 43 | 44 | def test_count_emoji() -> None: 45 | # assert _count_emoji("\u00e2\u009d\u00a4") == {"\u00e2\u009d\u00a4": 1} 46 | assert _count_emoji("👍👍😋😋❤") == {"👍": 2, "😋": 2, "❤": 1} 47 | assert _format_emojicount(_count_emoji("👍👍😋😋❤")) == "2x 😋, 2x 👍, 1x ❤" 48 | 49 | 50 | def _most_used_emoji(msgs: Iterator[str]) -> Counter: 51 | c: TCounter[str] = Counter() 52 | for m in msgs: 53 | c += Counter(_count_emoji(m)) 54 | return c 55 | 56 | 57 | def _convo_participants_key_dir(m: Message) -> str: 58 | # Preserves message direction 59 | return m.from_name + " -> " + m.to_name 60 | 61 | 62 | def _convo_participants_key_undir(m: Message) -> str: 63 | # Disregards message direction 64 | return " <-> ".join(sorted((m.to_name, m.from_name))) 65 | 66 | 67 | def _calendar(msgs: List[Message]) -> Dict[date, List[Message]]: 68 | def datekey(m: Message): 69 | return m.timestamp.date() 70 | 71 | grouped = groupby(sorted(msgs, key=datekey), key=datekey) 72 | msgs_per_date: Dict[date, List[Message]] = defaultdict(list) 73 | msgs_per_date.update({k: list(v) for k, v in grouped}) 74 | return msgs_per_date 75 | 76 | 77 | def _filter_author(msgs: list[Message], name: str) -> list[Message]: 78 | return [m for m in msgs if name in m.from_name] 79 | 80 | 81 | def _active_days(msgs: list[Message]) -> set[date]: 82 | return {m.timestamp.date() for m in msgs} 83 | -------------------------------------------------------------------------------- /data/private/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErikBjare/chatalysis/1b33b39f5cf82f8782b562b1f36cd6eb04c209aa/data/private/.empty -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "atomicwrites" 3 | version = "1.4.0" 4 | description = "Atomic file writes." 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 8 | 9 | [[package]] 10 | name = "attrs" 11 | version = "21.4.0" 12 | description = "Classes Without Boilerplate" 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 16 | 17 | [package.extras] 18 | 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"] 19 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 20 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] 21 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] 22 | 23 | [[package]] 24 | name = "black" 25 | version = "22.1.0" 26 | description = "The uncompromising code formatter." 27 | category = "dev" 28 | optional = false 29 | python-versions = ">=3.6.2" 30 | 31 | [package.dependencies] 32 | click = ">=8.0.0" 33 | mypy-extensions = ">=0.4.3" 34 | pathspec = ">=0.9.0" 35 | platformdirs = ">=2" 36 | tomli = ">=1.1.0" 37 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 38 | 39 | [package.extras] 40 | colorama = ["colorama (>=0.4.3)"] 41 | d = ["aiohttp (>=3.7.4)"] 42 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 43 | uvloop = ["uvloop (>=0.15.2)"] 44 | 45 | [[package]] 46 | name = "click" 47 | version = "8.0.4" 48 | description = "Composable command line interface toolkit" 49 | category = "dev" 50 | optional = false 51 | python-versions = ">=3.6" 52 | 53 | [package.dependencies] 54 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 55 | 56 | [[package]] 57 | name = "colorama" 58 | version = "0.4.4" 59 | description = "Cross-platform colored terminal text." 60 | category = "dev" 61 | optional = false 62 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 63 | 64 | [[package]] 65 | name = "coverage" 66 | version = "6.4.2" 67 | description = "Code coverage measurement for Python" 68 | category = "dev" 69 | optional = false 70 | python-versions = ">=3.7" 71 | 72 | [package.dependencies] 73 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 74 | 75 | [package.extras] 76 | toml = ["tomli"] 77 | 78 | [[package]] 79 | name = "cycler" 80 | version = "0.11.0" 81 | description = "Composable style cycles" 82 | category = "main" 83 | optional = false 84 | python-versions = ">=3.6" 85 | 86 | [[package]] 87 | name = "fonttools" 88 | version = "4.31.2" 89 | description = "Tools to manipulate font files" 90 | category = "main" 91 | optional = false 92 | python-versions = ">=3.7" 93 | 94 | [package.extras] 95 | 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"] 96 | graphite = ["lz4 (>=1.7.4.2)"] 97 | interpolatable = ["scipy", "munkres"] 98 | lxml = ["lxml (>=4.0,<5)"] 99 | pathops = ["skia-pathops (>=0.5.0)"] 100 | plot = ["matplotlib"] 101 | symfont = ["sympy"] 102 | type1 = ["xattr"] 103 | ufo = ["fs (>=2.2.0,<3)"] 104 | unicode = ["unicodedata2 (>=14.0.0)"] 105 | woff = ["zopfli (>=0.1.4)", "brotlicffi (>=0.8.0)", "brotli (>=1.0.1)"] 106 | 107 | [[package]] 108 | name = "iniconfig" 109 | version = "1.1.1" 110 | description = "iniconfig: brain-dead simple config-ini parsing" 111 | category = "dev" 112 | optional = false 113 | python-versions = "*" 114 | 115 | [[package]] 116 | name = "joblib" 117 | version = "1.1.0" 118 | description = "Lightweight pipelining with Python functions" 119 | category = "main" 120 | optional = false 121 | python-versions = ">=3.6" 122 | 123 | [[package]] 124 | name = "kiwisolver" 125 | version = "1.4.0" 126 | description = "A fast implementation of the Cassowary constraint solver" 127 | category = "main" 128 | optional = false 129 | python-versions = ">=3.7" 130 | 131 | [[package]] 132 | name = "matplotlib" 133 | version = "3.5.1" 134 | description = "Python plotting package" 135 | category = "main" 136 | optional = false 137 | python-versions = ">=3.7" 138 | 139 | [package.dependencies] 140 | cycler = ">=0.10" 141 | fonttools = ">=4.22.0" 142 | kiwisolver = ">=1.0.1" 143 | numpy = ">=1.17" 144 | packaging = ">=20.0" 145 | pillow = ">=6.2.0" 146 | pyparsing = ">=2.2.1" 147 | python-dateutil = ">=2.7" 148 | setuptools_scm = ">=4" 149 | 150 | [[package]] 151 | name = "mypy" 152 | version = "0.941" 153 | description = "Optional static typing for Python" 154 | category = "dev" 155 | optional = false 156 | python-versions = ">=3.6" 157 | 158 | [package.dependencies] 159 | mypy-extensions = ">=0.4.3" 160 | tomli = ">=1.1.0" 161 | typing-extensions = ">=3.10" 162 | 163 | [package.extras] 164 | dmypy = ["psutil (>=4.0)"] 165 | python2 = ["typed-ast (>=1.4.0,<2)"] 166 | reports = ["lxml"] 167 | 168 | [[package]] 169 | name = "mypy-extensions" 170 | version = "0.4.3" 171 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 172 | category = "dev" 173 | optional = false 174 | python-versions = "*" 175 | 176 | [[package]] 177 | name = "numpy" 178 | version = "1.22.3" 179 | description = "NumPy is the fundamental package for array computing with Python." 180 | category = "main" 181 | optional = false 182 | python-versions = ">=3.8" 183 | 184 | [[package]] 185 | name = "packaging" 186 | version = "21.3" 187 | description = "Core utilities for Python packages" 188 | category = "main" 189 | optional = false 190 | python-versions = ">=3.6" 191 | 192 | [package.dependencies] 193 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 194 | 195 | [[package]] 196 | name = "pathspec" 197 | version = "0.9.0" 198 | description = "Utility library for gitignore style pattern matching of file paths." 199 | category = "dev" 200 | optional = false 201 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 202 | 203 | [[package]] 204 | name = "pillow" 205 | version = "9.0.1" 206 | description = "Python Imaging Library (Fork)" 207 | category = "main" 208 | optional = false 209 | python-versions = ">=3.7" 210 | 211 | [[package]] 212 | name = "platformdirs" 213 | version = "2.5.1" 214 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 215 | category = "dev" 216 | optional = false 217 | python-versions = ">=3.7" 218 | 219 | [package.extras] 220 | docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] 221 | test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] 222 | 223 | [[package]] 224 | name = "pluggy" 225 | version = "1.0.0" 226 | description = "plugin and hook calling mechanisms for python" 227 | category = "dev" 228 | optional = false 229 | python-versions = ">=3.6" 230 | 231 | [package.extras] 232 | dev = ["pre-commit", "tox"] 233 | testing = ["pytest", "pytest-benchmark"] 234 | 235 | [[package]] 236 | name = "py" 237 | version = "1.11.0" 238 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 239 | category = "dev" 240 | optional = false 241 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 242 | 243 | [[package]] 244 | name = "pyparsing" 245 | version = "3.0.7" 246 | description = "Python parsing module" 247 | category = "main" 248 | optional = false 249 | python-versions = ">=3.6" 250 | 251 | [package.extras] 252 | diagrams = ["jinja2", "railroad-diagrams"] 253 | 254 | [[package]] 255 | name = "pytest" 256 | version = "7.1.1" 257 | description = "pytest: simple powerful testing with Python" 258 | category = "dev" 259 | optional = false 260 | python-versions = ">=3.7" 261 | 262 | [package.dependencies] 263 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 264 | attrs = ">=19.2.0" 265 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 266 | iniconfig = "*" 267 | packaging = "*" 268 | pluggy = ">=0.12,<2.0" 269 | py = ">=1.8.2" 270 | tomli = ">=1.0.0" 271 | 272 | [package.extras] 273 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 274 | 275 | [[package]] 276 | name = "pytest-cov" 277 | version = "3.0.0" 278 | description = "Pytest plugin for measuring coverage." 279 | category = "dev" 280 | optional = false 281 | python-versions = ">=3.6" 282 | 283 | [package.dependencies] 284 | coverage = {version = ">=5.2.1", extras = ["toml"]} 285 | pytest = ">=4.6" 286 | 287 | [package.extras] 288 | testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] 289 | 290 | [[package]] 291 | name = "python-dateutil" 292 | version = "2.8.2" 293 | description = "Extensions to the standard Python datetime module" 294 | category = "main" 295 | optional = false 296 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 297 | 298 | [package.dependencies] 299 | six = ">=1.5" 300 | 301 | [[package]] 302 | name = "setuptools-scm" 303 | version = "6.4.2" 304 | description = "the blessed package to manage your versions by scm tags" 305 | category = "main" 306 | optional = false 307 | python-versions = ">=3.6" 308 | 309 | [package.dependencies] 310 | packaging = ">=20.0" 311 | tomli = ">=1.0.0" 312 | 313 | [package.extras] 314 | test = ["pytest (>=6.2)", "virtualenv (>20)"] 315 | toml = ["setuptools (>=42)"] 316 | 317 | [[package]] 318 | name = "six" 319 | version = "1.16.0" 320 | description = "Python 2 and 3 compatibility utilities" 321 | category = "main" 322 | optional = false 323 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 324 | 325 | [[package]] 326 | name = "tabulate" 327 | version = "0.8.9" 328 | description = "Pretty-print tabular data" 329 | category = "main" 330 | optional = false 331 | python-versions = "*" 332 | 333 | [package.extras] 334 | widechars = ["wcwidth"] 335 | 336 | [[package]] 337 | name = "tomli" 338 | version = "2.0.1" 339 | description = "A lil' TOML parser" 340 | category = "main" 341 | optional = false 342 | python-versions = ">=3.7" 343 | 344 | [[package]] 345 | name = "types-tabulate" 346 | version = "0.8.6" 347 | description = "Typing stubs for tabulate" 348 | category = "dev" 349 | optional = false 350 | python-versions = "*" 351 | 352 | [[package]] 353 | name = "typing-extensions" 354 | version = "4.1.1" 355 | description = "Backported and Experimental Type Hints for Python 3.6+" 356 | category = "dev" 357 | optional = false 358 | python-versions = ">=3.6" 359 | 360 | [metadata] 361 | lock-version = "1.1" 362 | python-versions = "^3.9" 363 | content-hash = "2541f284bcc2ebabf4a15beb0b11410938f9077e147ed02235a9c231b37ddfc4" 364 | 365 | [metadata.files] 366 | atomicwrites = [ 367 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 368 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 369 | ] 370 | attrs = [ 371 | {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, 372 | {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, 373 | ] 374 | black = [ 375 | {file = "black-22.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1297c63b9e1b96a3d0da2d85d11cd9bf8664251fd69ddac068b98dc4f34f73b6"}, 376 | {file = "black-22.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2ff96450d3ad9ea499fc4c60e425a1439c2120cbbc1ab959ff20f7c76ec7e866"}, 377 | {file = "black-22.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e21e1f1efa65a50e3960edd068b6ae6d64ad6235bd8bfea116a03b21836af71"}, 378 | {file = "black-22.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f69158a7d120fd641d1fa9a921d898e20d52e44a74a6fbbcc570a62a6bc8ab"}, 379 | {file = "black-22.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:228b5ae2c8e3d6227e4bde5920d2fc66cc3400fde7bcc74f480cb07ef0b570d5"}, 380 | {file = "black-22.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b1a5ed73ab4c482208d20434f700d514f66ffe2840f63a6252ecc43a9bc77e8a"}, 381 | {file = "black-22.1.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35944b7100af4a985abfcaa860b06af15590deb1f392f06c8683b4381e8eeaf0"}, 382 | {file = "black-22.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7835fee5238fc0a0baf6c9268fb816b5f5cd9b8793423a75e8cd663c48d073ba"}, 383 | {file = "black-22.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dae63f2dbf82882fa3b2a3c49c32bffe144970a573cd68d247af6560fc493ae1"}, 384 | {file = "black-22.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fa1db02410b1924b6749c245ab38d30621564e658297484952f3d8a39fce7e8"}, 385 | {file = "black-22.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c8226f50b8c34a14608b848dc23a46e5d08397d009446353dad45e04af0c8e28"}, 386 | {file = "black-22.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2d6f331c02f0f40aa51a22e479c8209d37fcd520c77721c034517d44eecf5912"}, 387 | {file = "black-22.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:742ce9af3086e5bd07e58c8feb09dbb2b047b7f566eb5f5bc63fd455814979f3"}, 388 | {file = "black-22.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fdb8754b453fb15fad3f72cd9cad3e16776f0964d67cf30ebcbf10327a3777a3"}, 389 | {file = "black-22.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5660feab44c2e3cb24b2419b998846cbb01c23c7fe645fee45087efa3da2d61"}, 390 | {file = "black-22.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:6f2f01381f91c1efb1451998bd65a129b3ed6f64f79663a55fe0e9b74a5f81fd"}, 391 | {file = "black-22.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:efbadd9b52c060a8fc3b9658744091cb33c31f830b3f074422ed27bad2b18e8f"}, 392 | {file = "black-22.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8871fcb4b447206904932b54b567923e5be802b9b19b744fdff092bd2f3118d0"}, 393 | {file = "black-22.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ccad888050f5393f0d6029deea2a33e5ae371fd182a697313bdbd835d3edaf9c"}, 394 | {file = "black-22.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07e5c049442d7ca1a2fc273c79d1aecbbf1bc858f62e8184abe1ad175c4f7cc2"}, 395 | {file = "black-22.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:373922fc66676133ddc3e754e4509196a8c392fec3f5ca4486673e685a421321"}, 396 | {file = "black-22.1.0-py3-none-any.whl", hash = "sha256:3524739d76b6b3ed1132422bf9d82123cd1705086723bc3e235ca39fd21c667d"}, 397 | {file = "black-22.1.0.tar.gz", hash = "sha256:a7c0192d35635f6fc1174be575cb7915e92e5dd629ee79fdaf0dcfa41a80afb5"}, 398 | ] 399 | click = [] 400 | colorama = [ 401 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 402 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 403 | ] 404 | coverage = [] 405 | cycler = [ 406 | {file = "cycler-0.11.0-py3-none-any.whl", hash = "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3"}, 407 | {file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"}, 408 | ] 409 | fonttools = [] 410 | iniconfig = [ 411 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 412 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 413 | ] 414 | joblib = [ 415 | {file = "joblib-1.1.0-py2.py3-none-any.whl", hash = "sha256:f21f109b3c7ff9d95f8387f752d0d9c34a02aa2f7060c2135f465da0e5160ff6"}, 416 | {file = "joblib-1.1.0.tar.gz", hash = "sha256:4158fcecd13733f8be669be0683b96ebdbbd38d23559f54dca7205aea1bf1e35"}, 417 | ] 418 | kiwisolver = [ 419 | {file = "kiwisolver-1.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:70e7b7a4ebeddef423115ea31857732fc04e0f38dd1e6385e1af05b6164a3d0f"}, 420 | {file = "kiwisolver-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:384b5076b2c0172003abca9ba8b8c5efcaaffd31616f3f5e0a09dcc34772d012"}, 421 | {file = "kiwisolver-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:334a7e3d498a0a791245f0964c746d0414e9b13aef73237f0d798a2101fdbae9"}, 422 | {file = "kiwisolver-1.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:734e943ae519cdb8534d9053f478417c525ec921c06896ec7119e65d9ea4a687"}, 423 | {file = "kiwisolver-1.4.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:65cbdbe14dc5988e362eb15e02dd24c6724238cb235132f812f1e3a29a61a3de"}, 424 | {file = "kiwisolver-1.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf0080449d6ea39b817d85abd2c20d2d42fd9b1587544d64371d28d93c379cf"}, 425 | {file = "kiwisolver-1.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd0223a3a4ddcc0d0e06c6cfeb0adde2bc19c08b4c7fc79d48dac2486a4b115b"}, 426 | {file = "kiwisolver-1.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed30c5e58e578a2981c67346b2569e04120d1b80fa6906c207fe824d24603313"}, 427 | {file = "kiwisolver-1.4.0-cp310-cp310-win32.whl", hash = "sha256:ed937691f522cc2362c280c903837a4e35195659b9935b598e3cd448db863605"}, 428 | {file = "kiwisolver-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:576ba51b9f4e4d0d583c1cd257f53397bdc5e66a5e49fe68712f658426115777"}, 429 | {file = "kiwisolver-1.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2467fe5fff6ed2a728e10dca9b1f37e9b911ca5b228a7d8990c8e3abf80c1724"}, 430 | {file = "kiwisolver-1.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff7ae6fb6dce2f520b2d46efc801605fa1378fb19bb4580aebc6174eab05a0"}, 431 | {file = "kiwisolver-1.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:313724e85fd14d581a939fa02424f4dc772fd914bc04499a8a6377d47313b966"}, 432 | {file = "kiwisolver-1.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb997d1631b20745b18674d68dd6f1d9d45db512efd5fe0f162a5d4a6bbdd211"}, 433 | {file = "kiwisolver-1.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97372c837add54e3e64a811464b14bb01428c4e9256072b6296f04157ea23246"}, 434 | {file = "kiwisolver-1.4.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4471a48f53d20d49f263ca888aab77b754525ef35e6767657e1a44a724a8b0af"}, 435 | {file = "kiwisolver-1.4.0-cp37-cp37m-win32.whl", hash = "sha256:1cf8c81e8a5fb4f5dcbd473fdb619b895313d29b7c60e4545827dcc6efbd8efc"}, 436 | {file = "kiwisolver-1.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:87367ba1ad3819f7189fe8faff5f75a7603f526576033e7b86e10b598f8790b2"}, 437 | {file = "kiwisolver-1.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:139c75216e5875ee5f8f4f7adcc3cd339f46f0d66bda2e10d8d21386d635476f"}, 438 | {file = "kiwisolver-1.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:895b2df026006ff7434b03ca495983d0d26da96f6d58414c77d616747ee77e34"}, 439 | {file = "kiwisolver-1.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cbf9aa926de224af15c974750fecdc7d2c0043428372acaaf61216e202abbf21"}, 440 | {file = "kiwisolver-1.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cd1f81bc35ec24cb82a7d0b805521e3d71b25b8a493d5810d18dc29644c6ef8"}, 441 | {file = "kiwisolver-1.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199f32bf6f3d3e2246024326497513c5c49c62aecee86f0ac019f5991978d505"}, 442 | {file = "kiwisolver-1.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af6a7c956a45ee721e4263f5823e1a3b2e6b21a7e2b3646b3794e000620609d0"}, 443 | {file = "kiwisolver-1.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3891527ec51b0365bb50de9bf826ce3d5b1adc276685b2779889762437bbd359"}, 444 | {file = "kiwisolver-1.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:14f43edc25daa0646d4b4e86c2ebdd32d785ab73a65a570130a3d234a4554b07"}, 445 | {file = "kiwisolver-1.4.0-cp38-cp38-win32.whl", hash = "sha256:5ecf82bb25cec7df4bfcf37afe49f6f6202b4fa4029be7cb0848ed319c72d356"}, 446 | {file = "kiwisolver-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:34e2e39a219b203fa3a82af5b9f8d386a8718677de7a9b82a9634e292a8f4e0a"}, 447 | {file = "kiwisolver-1.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6c19457f58941da61681efaabd5b1c37893108a2f922b9b19538f6921911186d"}, 448 | {file = "kiwisolver-1.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a0a6f3d5063e7fd6662e4773778ad2cb36e598abc6eb171af4a072ca86b441d0"}, 449 | {file = "kiwisolver-1.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:676f9fac93f97f529dc80b5d6731099fad337549408e8bdd929343b7cb679797"}, 450 | {file = "kiwisolver-1.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4b70f0729947d6327cd659e1b3477ced44a317a4ba441238b2a3642990f0ebd7"}, 451 | {file = "kiwisolver-1.4.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:925a32900fc16430ba0dec2c0fca2e776eaf2fdc0930d5552be0a59e23304001"}, 452 | {file = "kiwisolver-1.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17ec8bd4e162fd0a8723467395c5bb16fd665a528b78e9339886c82965ed8efb"}, 453 | {file = "kiwisolver-1.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b4d1db32a4f1682df1480fd68eb1400235ac8f9ad8932e1624fdb23eb891904"}, 454 | {file = "kiwisolver-1.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38ebc0cb30ed2f59bd15e23591a53698005123e90e880f1af4600fcdbe4912e1"}, 455 | {file = "kiwisolver-1.4.0-cp39-cp39-win32.whl", hash = "sha256:8f63b981678ca894bb665bcd5043bde2c9ba600e69df730c1ceeadd73ddbcb8c"}, 456 | {file = "kiwisolver-1.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:b1ff5582bf55e85728119c5a23f695b8e408e15eee7d0f5effe0ee8ad1f8b523"}, 457 | {file = "kiwisolver-1.4.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c29496625c61e18d97a6f6c2f2a55759ca8290fc21a751bc57824599c431c0d2"}, 458 | {file = "kiwisolver-1.4.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:71d44a6a59ea53d41e5950a42ec496fa821bd86f292fb3e10aa1b3932ecfc65e"}, 459 | {file = "kiwisolver-1.4.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf2030bf18c21bf91fa9cf6a403a765519c9168bd7a91ba1d66d5c7f70ded1e"}, 460 | {file = "kiwisolver-1.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:5ca92de8e48678a2cbbd90adb10773e3553bb9fd1c090bf0dfe5fc3337a181ea"}, 461 | {file = "kiwisolver-1.4.0.tar.gz", hash = "sha256:7508b01e211178a85d21f1f87029846b77b2404a4c68cbd14748d4d4142fa3b8"}, 462 | ] 463 | matplotlib = [ 464 | {file = "matplotlib-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:456cc8334f6d1124e8ff856b42d2cc1c84335375a16448189999496549f7182b"}, 465 | {file = "matplotlib-3.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8a77906dc2ef9b67407cec0bdbf08e3971141e535db888974a915be5e1e3efc6"}, 466 | {file = "matplotlib-3.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e70ae6475cfd0fad3816dcbf6cac536dc6f100f7474be58d59fa306e6e768a4"}, 467 | {file = "matplotlib-3.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53273c5487d1c19c3bc03b9eb82adaf8456f243b97ed79d09dded747abaf1235"}, 468 | {file = "matplotlib-3.5.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3b6f3fd0d8ca37861c31e9a7cab71a0ef14c639b4c95654ea1dd153158bf0df"}, 469 | {file = "matplotlib-3.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8c87cdaf06fd7b2477f68909838ff4176f105064a72ca9d24d3f2a29f73d393"}, 470 | {file = "matplotlib-3.5.1-cp310-cp310-win32.whl", hash = "sha256:e2f28a07b4f82abb40267864ad7b3a4ed76f1b1663e81c7efc84a9b9248f672f"}, 471 | {file = "matplotlib-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:d70a32ee1f8b55eed3fd4e892f0286df8cccc7e0475c11d33b5d0a148f5c7599"}, 472 | {file = "matplotlib-3.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:68fa30cec89b6139dc559ed6ef226c53fd80396da1919a1b5ef672c911aaa767"}, 473 | {file = "matplotlib-3.5.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e3484d8455af3fdb0424eae1789af61f6a79da0c80079125112fd5c1b604218"}, 474 | {file = "matplotlib-3.5.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e293b16cf303fe82995e41700d172a58a15efc5331125d08246b520843ef21ee"}, 475 | {file = "matplotlib-3.5.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e3520a274a0e054e919f5b3279ee5dbccf5311833819ccf3399dab7c83e90a25"}, 476 | {file = "matplotlib-3.5.1-cp37-cp37m-win32.whl", hash = "sha256:2252bfac85cec7af4a67e494bfccf9080bcba8a0299701eab075f48847cca907"}, 477 | {file = "matplotlib-3.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:abf67e05a1b7f86583f6ebd01f69b693b9c535276f4e943292e444855870a1b8"}, 478 | {file = "matplotlib-3.5.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6c094e4bfecd2fa7f9adffd03d8abceed7157c928c2976899de282f3600f0a3d"}, 479 | {file = "matplotlib-3.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:506b210cc6e66a0d1c2bb765d055f4f6bc2745070fb1129203b67e85bbfa5c18"}, 480 | {file = "matplotlib-3.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b04fc29bcef04d4e2d626af28d9d892be6aba94856cb46ed52bcb219ceac8943"}, 481 | {file = "matplotlib-3.5.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:577ed20ec9a18d6bdedb4616f5e9e957b4c08563a9f985563a31fd5b10564d2a"}, 482 | {file = "matplotlib-3.5.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e486f60db0cd1c8d68464d9484fd2a94011c1ac8593d765d0211f9daba2bd535"}, 483 | {file = "matplotlib-3.5.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b71f3a7ca935fc759f2aed7cec06cfe10bc3100fadb5dbd9c435b04e557971e1"}, 484 | {file = "matplotlib-3.5.1-cp38-cp38-win32.whl", hash = "sha256:d24e5bb8028541ce25e59390122f5e48c8506b7e35587e5135efcb6471b4ac6c"}, 485 | {file = "matplotlib-3.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:778d398c4866d8e36ee3bf833779c940b5f57192fa0a549b3ad67bc4c822771b"}, 486 | {file = "matplotlib-3.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bb1c613908f11bac270bc7494d68b1ef6e7c224b7a4204d5dacf3522a41e2bc3"}, 487 | {file = "matplotlib-3.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:edf5e4e1d5fb22c18820e8586fb867455de3b109c309cb4fce3aaed85d9468d1"}, 488 | {file = "matplotlib-3.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:40e0d7df05e8efe60397c69b467fc8f87a2affeb4d562fe92b72ff8937a2b511"}, 489 | {file = "matplotlib-3.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a350ca685d9f594123f652ba796ee37219bf72c8e0fc4b471473d87121d6d34"}, 490 | {file = "matplotlib-3.5.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3e66497cd990b1a130e21919b004da2f1dc112132c01ac78011a90a0f9229778"}, 491 | {file = "matplotlib-3.5.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:87900c67c0f1728e6db17c6809ec05c025c6624dcf96a8020326ea15378fe8e7"}, 492 | {file = "matplotlib-3.5.1-cp39-cp39-win32.whl", hash = "sha256:b8a4fb2a0c5afbe9604f8a91d7d0f27b1832c3e0b5e365f95a13015822b4cd65"}, 493 | {file = "matplotlib-3.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:fe8d40c434a8e2c68d64c6d6a04e77f21791a93ff6afe0dce169597c110d3079"}, 494 | {file = "matplotlib-3.5.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:34a1fc29f8f96e78ec57a5eff5e8d8b53d3298c3be6df61e7aa9efba26929522"}, 495 | {file = "matplotlib-3.5.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b19a761b948e939a9e20173aaae76070025f0024fc8f7ba08bef22a5c8573afc"}, 496 | {file = "matplotlib-3.5.1-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6803299cbf4665eca14428d9e886de62e24f4223ac31ab9c5d6d5339a39782c7"}, 497 | {file = "matplotlib-3.5.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:14334b9902ec776461c4b8c6516e26b450f7ebe0b3ef8703bf5cdfbbaecf774a"}, 498 | {file = "matplotlib-3.5.1.tar.gz", hash = "sha256:b2e9810e09c3a47b73ce9cab5a72243a1258f61e7900969097a817232246ce1c"}, 499 | ] 500 | mypy = [] 501 | mypy-extensions = [ 502 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 503 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 504 | ] 505 | numpy = [ 506 | {file = "numpy-1.22.3-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:92bfa69cfbdf7dfc3040978ad09a48091143cffb778ec3b03fa170c494118d75"}, 507 | {file = "numpy-1.22.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8251ed96f38b47b4295b1ae51631de7ffa8260b5b087808ef09a39a9d66c97ab"}, 508 | {file = "numpy-1.22.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48a3aecd3b997bf452a2dedb11f4e79bc5bfd21a1d4cc760e703c31d57c84b3e"}, 509 | {file = "numpy-1.22.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3bae1a2ed00e90b3ba5f7bd0a7c7999b55d609e0c54ceb2b076a25e345fa9f4"}, 510 | {file = "numpy-1.22.3-cp310-cp310-win32.whl", hash = "sha256:f950f8845b480cffe522913d35567e29dd381b0dc7e4ce6a4a9f9156417d2430"}, 511 | {file = "numpy-1.22.3-cp310-cp310-win_amd64.whl", hash = "sha256:08d9b008d0156c70dc392bb3ab3abb6e7a711383c3247b410b39962263576cd4"}, 512 | {file = "numpy-1.22.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:201b4d0552831f7250a08d3b38de0d989d6f6e4658b709a02a73c524ccc6ffce"}, 513 | {file = "numpy-1.22.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f8c1f39caad2c896bc0018f699882b345b2a63708008be29b1f355ebf6f933fe"}, 514 | {file = "numpy-1.22.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:568dfd16224abddafb1cbcce2ff14f522abe037268514dd7e42c6776a1c3f8e5"}, 515 | {file = "numpy-1.22.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ca688e1b9b95d80250bca34b11a05e389b1420d00e87a0d12dc45f131f704a1"}, 516 | {file = "numpy-1.22.3-cp38-cp38-win32.whl", hash = "sha256:e7927a589df200c5e23c57970bafbd0cd322459aa7b1ff73b7c2e84d6e3eae62"}, 517 | {file = "numpy-1.22.3-cp38-cp38-win_amd64.whl", hash = "sha256:07a8c89a04997625236c5ecb7afe35a02af3896c8aa01890a849913a2309c676"}, 518 | {file = "numpy-1.22.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:2c10a93606e0b4b95c9b04b77dc349b398fdfbda382d2a39ba5a822f669a0123"}, 519 | {file = "numpy-1.22.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fade0d4f4d292b6f39951b6836d7a3c7ef5b2347f3c420cd9820a1d90d794802"}, 520 | {file = "numpy-1.22.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bfb1bb598e8229c2d5d48db1860bcf4311337864ea3efdbe1171fb0c5da515d"}, 521 | {file = "numpy-1.22.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97098b95aa4e418529099c26558eeb8486e66bd1e53a6b606d684d0c3616b168"}, 522 | {file = "numpy-1.22.3-cp39-cp39-win32.whl", hash = "sha256:fdf3c08bce27132395d3c3ba1503cac12e17282358cb4bddc25cc46b0aca07aa"}, 523 | {file = "numpy-1.22.3-cp39-cp39-win_amd64.whl", hash = "sha256:639b54cdf6aa4f82fe37ebf70401bbb74b8508fddcf4797f9fe59615b8c5813a"}, 524 | {file = "numpy-1.22.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c34ea7e9d13a70bf2ab64a2532fe149a9aced424cd05a2c4ba662fd989e3e45f"}, 525 | {file = "numpy-1.22.3.zip", hash = "sha256:dbc7601a3b7472d559dc7b933b18b4b66f9aa7452c120e87dfb33d02008c8a18"}, 526 | ] 527 | packaging = [ 528 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 529 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 530 | ] 531 | pathspec = [ 532 | {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, 533 | {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, 534 | ] 535 | pillow = [ 536 | {file = "Pillow-9.0.1-1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5d24e1d674dd9d72c66ad3ea9131322819ff86250b30dc5821cbafcfa0b96b4"}, 537 | {file = "Pillow-9.0.1-1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2632d0f846b7c7600edf53c48f8f9f1e13e62f66a6dbc15191029d950bfed976"}, 538 | {file = "Pillow-9.0.1-1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b9618823bd237c0d2575283f2939655f54d51b4527ec3972907a927acbcc5bfc"}, 539 | {file = "Pillow-9.0.1-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:9bfdb82cdfeccec50aad441afc332faf8606dfa5e8efd18a6692b5d6e79f00fd"}, 540 | {file = "Pillow-9.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5100b45a4638e3c00e4d2320d3193bdabb2d75e79793af7c3eb139e4f569f16f"}, 541 | {file = "Pillow-9.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:528a2a692c65dd5cafc130de286030af251d2ee0483a5bf50c9348aefe834e8a"}, 542 | {file = "Pillow-9.0.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f29d831e2151e0b7b39981756d201f7108d3d215896212ffe2e992d06bfe049"}, 543 | {file = "Pillow-9.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:855c583f268edde09474b081e3ddcd5cf3b20c12f26e0d434e1386cc5d318e7a"}, 544 | {file = "Pillow-9.0.1-cp310-cp310-win32.whl", hash = "sha256:d9d7942b624b04b895cb95af03a23407f17646815495ce4547f0e60e0b06f58e"}, 545 | {file = "Pillow-9.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:81c4b81611e3a3cb30e59b0cf05b888c675f97e3adb2c8672c3154047980726b"}, 546 | {file = "Pillow-9.0.1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:413ce0bbf9fc6278b2d63309dfeefe452835e1c78398efb431bab0672fe9274e"}, 547 | {file = "Pillow-9.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80fe64a6deb6fcfdf7b8386f2cf216d329be6f2781f7d90304351811fb591360"}, 548 | {file = "Pillow-9.0.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cef9c85ccbe9bee00909758936ea841ef12035296c748aaceee535969e27d31b"}, 549 | {file = "Pillow-9.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d19397351f73a88904ad1aee421e800fe4bbcd1aeee6435fb62d0a05ccd1030"}, 550 | {file = "Pillow-9.0.1-cp37-cp37m-win32.whl", hash = "sha256:d21237d0cd37acded35154e29aec853e945950321dd2ffd1a7d86fe686814669"}, 551 | {file = "Pillow-9.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ede5af4a2702444a832a800b8eb7f0a7a1c0eed55b644642e049c98d589e5092"}, 552 | {file = "Pillow-9.0.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:b5b3f092fe345c03bca1e0b687dfbb39364b21ebb8ba90e3fa707374b7915204"}, 553 | {file = "Pillow-9.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:335ace1a22325395c4ea88e00ba3dc89ca029bd66bd5a3c382d53e44f0ccd77e"}, 554 | {file = "Pillow-9.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db6d9fac65bd08cea7f3540b899977c6dee9edad959fa4eaf305940d9cbd861c"}, 555 | {file = "Pillow-9.0.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f154d173286a5d1863637a7dcd8c3437bb557520b01bddb0be0258dcb72696b5"}, 556 | {file = "Pillow-9.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d4b1341ac07ae07eb2cc682f459bec932a380c3b122f5540432d8977e64eae"}, 557 | {file = "Pillow-9.0.1-cp38-cp38-win32.whl", hash = "sha256:effb7749713d5317478bb3acb3f81d9d7c7f86726d41c1facca068a04cf5bb4c"}, 558 | {file = "Pillow-9.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:7f7609a718b177bf171ac93cea9fd2ddc0e03e84d8fa4e887bdfc39671d46b00"}, 559 | {file = "Pillow-9.0.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:80ca33961ced9c63358056bd08403ff866512038883e74f3a4bf88ad3eb66838"}, 560 | {file = "Pillow-9.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1c3c33ac69cf059bbb9d1a71eeaba76781b450bc307e2291f8a4764d779a6b28"}, 561 | {file = "Pillow-9.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12875d118f21cf35604176872447cdb57b07126750a33748bac15e77f90f1f9c"}, 562 | {file = "Pillow-9.0.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:514ceac913076feefbeaf89771fd6febde78b0c4c1b23aaeab082c41c694e81b"}, 563 | {file = "Pillow-9.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3c5c79ab7dfce6d88f1ba639b77e77a17ea33a01b07b99840d6ed08031cb2a7"}, 564 | {file = "Pillow-9.0.1-cp39-cp39-win32.whl", hash = "sha256:718856856ba31f14f13ba885ff13874be7fefc53984d2832458f12c38205f7f7"}, 565 | {file = "Pillow-9.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:f25ed6e28ddf50de7e7ea99d7a976d6a9c415f03adcaac9c41ff6ff41b6d86ac"}, 566 | {file = "Pillow-9.0.1-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:011233e0c42a4a7836498e98c1acf5e744c96a67dd5032a6f666cc1fb97eab97"}, 567 | {file = "Pillow-9.0.1-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:253e8a302a96df6927310a9d44e6103055e8fb96a6822f8b7f514bb7ef77de56"}, 568 | {file = "Pillow-9.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6295f6763749b89c994fcb6d8a7f7ce03c3992e695f89f00b741b4580b199b7e"}, 569 | {file = "Pillow-9.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:a9f44cd7e162ac6191491d7249cceb02b8116b0f7e847ee33f739d7cb1ea1f70"}, 570 | {file = "Pillow-9.0.1.tar.gz", hash = "sha256:6c8bc8238a7dfdaf7a75f5ec5a663f4173f8c367e5a39f87e720495e1eed75fa"}, 571 | ] 572 | platformdirs = [] 573 | pluggy = [ 574 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 575 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 576 | ] 577 | py = [ 578 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 579 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 580 | ] 581 | pyparsing = [ 582 | {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, 583 | {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, 584 | ] 585 | pytest = [] 586 | pytest-cov = [ 587 | {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, 588 | {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, 589 | ] 590 | python-dateutil = [ 591 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 592 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 593 | ] 594 | setuptools-scm = [ 595 | {file = "setuptools_scm-6.4.2-py3-none-any.whl", hash = "sha256:acea13255093849de7ccb11af9e1fb8bde7067783450cee9ef7a93139bddf6d4"}, 596 | {file = "setuptools_scm-6.4.2.tar.gz", hash = "sha256:6833ac65c6ed9711a4d5d2266f8024cfa07c533a0e55f4c12f6eff280a5a9e30"}, 597 | ] 598 | six = [ 599 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 600 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 601 | ] 602 | tabulate = [ 603 | {file = "tabulate-0.8.9-py3-none-any.whl", hash = "sha256:d7c013fe7abbc5e491394e10fa845f8f32fe54f8dc60c6622c6cf482d25d47e4"}, 604 | {file = "tabulate-0.8.9.tar.gz", hash = "sha256:eb1d13f25760052e8931f2ef80aaf6045a6cceb47514db8beab24cded16f13a7"}, 605 | ] 606 | tomli = [ 607 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 608 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 609 | ] 610 | types-tabulate = [] 611 | typing-extensions = [ 612 | {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, 613 | {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, 614 | ] 615 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "chatalysis" 3 | version = "0.2.0" 4 | description = "Analyze your chat conversations" 5 | authors = ["Erik Bjäreholt "] 6 | license = "MPL-2.0" 7 | packages = [ 8 | { include = "chatalysis" } 9 | ] 10 | 11 | [tool.poetry.scripts] 12 | chatalysis = "chatalysis.main:main" 13 | 14 | [tool.poetry.dependencies] 15 | python = "^3.9" 16 | joblib = "*" 17 | tabulate = "*" 18 | matplotlib = "*" 19 | 20 | [tool.poetry.dev-dependencies] 21 | mypy = "*" 22 | pytest = "*" 23 | black = "*" 24 | types-tabulate = "*" 25 | pytest-cov = "^3.0.0" 26 | 27 | [tool.pytest.ini_options] 28 | minversion = "6.0" 29 | #addopts = "--cov=quantifiedme --cov-report=xml --cov-report=html --cov-report=term" # --profile --cov-report=term 30 | testpaths = [ 31 | "chatalysis", 32 | ] 33 | python_files = ["*.py",] 34 | filterwarnings = ["ignore::DeprecationWarning",] 35 | 36 | [build-system] 37 | requires = ["poetry>=0.12"] 38 | build-backend = "poetry.masonry.api" 39 | --------------------------------------------------------------------------------