convoviz 0.2.4__py3-none-any.whl → 0.2.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
convoviz/config.py CHANGED
@@ -19,7 +19,7 @@ class MarkdownConfig(BaseModel):
19
19
  """Configuration for markdown output."""
20
20
 
21
21
  latex_delimiters: Literal["default", "dollars"] = "default"
22
- flavor: Literal["obsidian", "standard"] = "obsidian"
22
+ flavor: Literal["obsidian", "standard"] = "standard"
23
23
 
24
24
 
25
25
  class YAMLConfig(BaseModel):
@@ -74,6 +74,8 @@ class GraphConfig(BaseModel):
74
74
  figsize: tuple[int, int] = (10, 6)
75
75
  dpi: int = 300
76
76
  timezone: Literal["utc", "local"] = "local"
77
+ generate_monthly_breakdowns: bool = False
78
+ generate_yearly_breakdowns: bool = False
77
79
 
78
80
 
79
81
  class ConvovizConfig(BaseModel):
convoviz/interactive.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Interactive configuration prompts using questionary."""
2
2
 
3
3
  from pathlib import Path
4
+ from typing import Literal, Protocol, cast
4
5
 
5
6
  from questionary import Choice, Style, checkbox, select
6
7
  from questionary import path as qst_path
@@ -25,6 +26,23 @@ CUSTOM_STYLE = Style(
25
26
  ]
26
27
  )
27
28
 
29
+ class _QuestionaryPrompt[T](Protocol):
30
+ def ask(self) -> T | None: ...
31
+
32
+
33
+ def _ask_or_cancel[T](prompt: _QuestionaryPrompt[T]) -> T:
34
+ """Ask a questionary prompt; treat Ctrl+C/Ctrl+D as cancelling the run.
35
+
36
+ questionary's `.ask()` returns `None` on cancellation (Ctrl+C / Ctrl+D). We
37
+ convert that to `KeyboardInterrupt` so callers can abort the whole
38
+ interactive session with a single Ctrl+C.
39
+ """
40
+
41
+ result = prompt.ask()
42
+ if result is None:
43
+ raise KeyboardInterrupt
44
+ return result
45
+
28
46
 
29
47
  def _validate_input_path(raw: str) -> bool | str:
30
48
  path = Path(raw)
@@ -67,22 +85,26 @@ def run_interactive_config(initial_config: ConvovizConfig | None = None) -> Conv
67
85
 
68
86
  # Prompt for input path
69
87
  input_default = str(config.input_path) if config.input_path else ""
70
- input_result = qst_path(
71
- "Enter the path to the export ZIP, conversations JSON, or extracted directory:",
72
- default=input_default,
73
- validate=_validate_input_path,
74
- style=CUSTOM_STYLE,
75
- ).ask()
88
+ input_result: str = _ask_or_cancel(
89
+ qst_path(
90
+ "Enter the path to the export ZIP, conversations JSON, or extracted directory:",
91
+ default=input_default,
92
+ validate=_validate_input_path,
93
+ style=CUSTOM_STYLE,
94
+ )
95
+ )
76
96
 
77
97
  if input_result:
78
98
  config.input_path = Path(input_result)
79
99
 
80
100
  # Prompt for output folder
81
- output_result = qst_path(
82
- "Enter the path to the output folder:",
83
- default=str(config.output_folder),
84
- style=CUSTOM_STYLE,
85
- ).ask()
101
+ output_result: str = _ask_or_cancel(
102
+ qst_path(
103
+ "Enter the path to the output folder:",
104
+ default=str(config.output_folder),
105
+ style=CUSTOM_STYLE,
106
+ )
107
+ )
86
108
 
87
109
  if output_result:
88
110
  config.output_folder = Path(output_result)
@@ -91,34 +113,46 @@ def run_interactive_config(initial_config: ConvovizConfig | None = None) -> Conv
91
113
  headers = config.message.author_headers
92
114
  for role in ["system", "user", "assistant", "tool"]:
93
115
  current = getattr(headers, role)
94
- result = qst_text(
95
- f"Enter the message header for '{role}':",
96
- default=current,
97
- validate=lambda t: validate_header(t)
98
- or "Must be a valid markdown header (e.g., # Title)",
99
- style=CUSTOM_STYLE,
100
- ).ask()
116
+ result: str = _ask_or_cancel(
117
+ qst_text(
118
+ f"Enter the message header for '{role}':",
119
+ default=current,
120
+ validate=lambda t: validate_header(t)
121
+ or "Must be a valid markdown header (e.g., # Title)",
122
+ style=CUSTOM_STYLE,
123
+ )
124
+ )
101
125
  if result:
102
126
  setattr(headers, role, result)
103
127
 
104
128
  # Prompt for LaTeX delimiters
105
- latex_result = select(
106
- "Select the LaTeX math delimiters:",
107
- choices=["default", "dollars"],
108
- default=config.conversation.markdown.latex_delimiters,
109
- style=CUSTOM_STYLE,
110
- ).ask()
129
+ latex_result = cast(
130
+ Literal["default", "dollars"],
131
+ _ask_or_cancel(
132
+ select(
133
+ "Select the LaTeX math delimiters:",
134
+ choices=["default", "dollars"],
135
+ default=config.conversation.markdown.latex_delimiters,
136
+ style=CUSTOM_STYLE,
137
+ )
138
+ ),
139
+ )
111
140
 
112
141
  if latex_result:
113
142
  config.conversation.markdown.latex_delimiters = latex_result
114
143
 
115
144
  # Prompt for markdown flavor
116
- flavor_result = select(
117
- "Select the markdown flavor:",
118
- choices=["obsidian", "standard"],
119
- default=config.conversation.markdown.flavor,
120
- style=CUSTOM_STYLE,
121
- ).ask()
145
+ flavor_result = cast(
146
+ Literal["obsidian", "standard"],
147
+ _ask_or_cancel(
148
+ select(
149
+ "Select the markdown flavor:",
150
+ choices=["obsidian", "standard"],
151
+ default=config.conversation.markdown.flavor,
152
+ style=CUSTOM_STYLE,
153
+ )
154
+ ),
155
+ )
122
156
 
123
157
  if flavor_result:
124
158
  config.conversation.markdown.flavor = flavor_result
@@ -141,27 +175,28 @@ def run_interactive_config(initial_config: ConvovizConfig | None = None) -> Conv
141
175
  ]
142
176
  ]
143
177
 
144
- selected = checkbox(
145
- "Select YAML metadata headers to include:",
146
- choices=yaml_choices,
147
- style=CUSTOM_STYLE,
148
- ).ask()
149
-
150
- if selected is not None:
151
- selected_set = set(selected)
152
- for field_name in [
153
- "title",
154
- "tags",
155
- "chat_link",
156
- "create_time",
157
- "update_time",
158
- "model",
159
- "used_plugins",
160
- "message_count",
161
- "content_types",
162
- "custom_instructions",
163
- ]:
164
- setattr(yaml_config, field_name, field_name in selected_set)
178
+ selected: list[str] = _ask_or_cancel(
179
+ checkbox(
180
+ "Select YAML metadata headers to include:",
181
+ choices=yaml_choices,
182
+ style=CUSTOM_STYLE,
183
+ )
184
+ )
185
+
186
+ selected_set = set(selected)
187
+ for field_name in [
188
+ "title",
189
+ "tags",
190
+ "chat_link",
191
+ "create_time",
192
+ "update_time",
193
+ "model",
194
+ "used_plugins",
195
+ "message_count",
196
+ "content_types",
197
+ "custom_instructions",
198
+ ]:
199
+ setattr(yaml_config, field_name, field_name in selected_set)
165
200
 
166
201
  # Prompt for font
167
202
  available_fonts = font_names()
@@ -169,12 +204,14 @@ def run_interactive_config(initial_config: ConvovizConfig | None = None) -> Conv
169
204
  current_font = (
170
205
  config.wordcloud.font_path.stem if config.wordcloud.font_path else available_fonts[0]
171
206
  )
172
- font_result = select(
173
- "Select the font for word clouds:",
174
- choices=available_fonts,
175
- default=current_font if current_font in available_fonts else available_fonts[0],
176
- style=CUSTOM_STYLE,
177
- ).ask()
207
+ font_result: str = _ask_or_cancel(
208
+ select(
209
+ "Select the font for word clouds:",
210
+ choices=available_fonts,
211
+ default=current_font if current_font in available_fonts else available_fonts[0],
212
+ style=CUSTOM_STYLE,
213
+ )
214
+ )
178
215
 
179
216
  if font_result:
180
217
  config.wordcloud.font_path = font_path(font_result)
@@ -182,26 +219,29 @@ def run_interactive_config(initial_config: ConvovizConfig | None = None) -> Conv
182
219
  # Prompt for colormap
183
220
  available_colormaps = colormaps()
184
221
  if available_colormaps:
185
- colormap_result = select(
186
- "Select the color theme for word clouds:",
187
- choices=available_colormaps,
188
- default=config.wordcloud.colormap
189
- if config.wordcloud.colormap in available_colormaps
190
- else available_colormaps[0],
191
- style=CUSTOM_STYLE,
192
- ).ask()
222
+ colormap_result: str = _ask_or_cancel(
223
+ select(
224
+ "Select the color theme for word clouds:",
225
+ choices=available_colormaps,
226
+ default=config.wordcloud.colormap
227
+ if config.wordcloud.colormap in available_colormaps
228
+ else available_colormaps[0],
229
+ style=CUSTOM_STYLE,
230
+ )
231
+ )
193
232
 
194
233
  if colormap_result:
195
234
  config.wordcloud.colormap = colormap_result
196
235
 
197
236
  # Prompt for custom stopwords
198
- stopwords_result = qst_text(
199
- "Enter custom stopwords (comma-separated):",
200
- default=config.wordcloud.custom_stopwords,
201
- style=CUSTOM_STYLE,
202
- ).ask()
203
-
204
- if stopwords_result is not None:
205
- config.wordcloud.custom_stopwords = stopwords_result
237
+ stopwords_result: str = _ask_or_cancel(
238
+ qst_text(
239
+ "Enter custom stopwords (comma-separated):",
240
+ default=config.wordcloud.custom_stopwords,
241
+ style=CUSTOM_STYLE,
242
+ )
243
+ )
244
+
245
+ config.wordcloud.custom_stopwords = stopwords_result
206
246
 
207
247
  return config
@@ -36,22 +36,29 @@ class Conversation(BaseModel):
36
36
 
37
37
  @property
38
38
  def all_message_nodes(self) -> list[Node]:
39
- """Get all nodes that have messages (including all branches)."""
39
+ """Get all nodes that have messages (including hidden/internal ones)."""
40
40
  return [node for node in self.node_mapping.values() if node.has_message]
41
41
 
42
- def nodes_by_author(self, *authors: AuthorRole) -> list[Node]:
42
+ @property
43
+ def visible_message_nodes(self) -> list[Node]:
44
+ """Get all nodes that have *visible* (non-hidden) messages."""
45
+ return [
46
+ node
47
+ for node in self.node_mapping.values()
48
+ if node.has_message and node.message is not None and not node.message.is_hidden
49
+ ]
50
+
51
+ def nodes_by_author(self, *authors: AuthorRole, include_hidden: bool = False) -> list[Node]:
43
52
  """Get nodes with messages from specified authors.
44
53
 
45
54
  Args:
46
55
  *authors: Author roles to filter by. Defaults to ("user",) if empty.
56
+ include_hidden: Whether to include hidden/internal messages.
47
57
  """
48
58
  if not authors:
49
59
  authors = ("user",)
50
- return [
51
- node
52
- for node in self.all_message_nodes
53
- if node.message and node.message.author.role in authors
54
- ]
60
+ nodes = self.all_message_nodes if include_hidden else self.visible_message_nodes
61
+ return [node for node in nodes if node.message and node.message.author.role in authors]
55
62
 
56
63
  @property
57
64
  def leaf_count(self) -> int:
@@ -65,9 +72,13 @@ class Conversation(BaseModel):
65
72
 
66
73
  @property
67
74
  def content_types(self) -> list[str]:
68
- """Get all unique content types in the conversation."""
75
+ """Get all unique content types in the conversation (excluding hidden messages)."""
69
76
  return list(
70
- {node.message.content.content_type for node in self.all_message_nodes if node.message}
77
+ {
78
+ node.message.content.content_type
79
+ for node in self.visible_message_nodes
80
+ if node.message
81
+ }
71
82
  )
72
83
 
73
84
  def message_count(self, *authors: AuthorRole) -> int:
@@ -8,6 +8,24 @@ from convoviz.exceptions import MessageContentError
8
8
  from convoviz.models import Conversation, Node
9
9
  from convoviz.renderers.yaml import render_yaml_header
10
10
 
11
+ # Length for shortened node IDs in markdown output (similar to Git short hashes)
12
+ SHORT_ID_LENGTH = 8
13
+
14
+
15
+ def shorten_id(node_id: str) -> str:
16
+ """Shorten a node ID for display in markdown.
17
+
18
+ Takes the first 8 characters of the ID, which is typically the first
19
+ segment of a UUID and provides sufficient uniqueness within a conversation.
20
+
21
+ Args:
22
+ node_id: The full node ID (often a UUID)
23
+
24
+ Returns:
25
+ Shortened ID string
26
+ """
27
+ return node_id[:SHORT_ID_LENGTH]
28
+
11
29
 
12
30
  def close_code_blocks(text: str) -> str:
13
31
  """Ensure all code blocks in the text are properly closed.
@@ -81,7 +99,7 @@ def render_message_header(role: str, headers: AuthorHeaders) -> str:
81
99
  return header_map.get(role, f"### {role.title()}")
82
100
 
83
101
 
84
- def render_node_header(node: Node, headers: AuthorHeaders, flavor: str = "obsidian") -> str:
102
+ def render_node_header(node: Node, headers: AuthorHeaders, flavor: str = "standard") -> str:
85
103
  """Render the header section of a node.
86
104
 
87
105
  Includes the node ID, parent link, and message author header.
@@ -105,15 +123,15 @@ def render_node_header(node: Node, headers: AuthorHeaders, flavor: str = "obsidi
105
123
 
106
124
  # Add parent link if parent has a message
107
125
  if node.parent_node and node.parent_node.message:
108
- parts.append(f"[⬆️](#^{node.parent_node.id})")
126
+ parts.append(f"[⬆️](#^{shorten_id(node.parent_node.id)})")
109
127
 
110
128
  author_header = render_message_header(node.message.author.role, headers)
111
- parts.append(f"{author_header} ^{node.id}")
129
+ parts.append(f"{author_header} ^{shorten_id(node.id)}")
112
130
 
113
131
  return "\n".join(parts) + "\n"
114
132
 
115
133
 
116
- def render_node_footer(node: Node, flavor: str = "obsidian") -> str:
134
+ def render_node_footer(node: Node, flavor: str = "standard") -> str:
117
135
  """Render the footer section of a node with child links.
118
136
 
119
137
  Args:
@@ -127,9 +145,11 @@ def render_node_footer(node: Node, flavor: str = "obsidian") -> str:
127
145
  return ""
128
146
 
129
147
  if len(node.children_nodes) == 1:
130
- return f"\n[⬇️](#^{node.children_nodes[0].id})\n"
148
+ return f"\n[⬇️](#^{shorten_id(node.children_nodes[0].id)})\n"
131
149
 
132
- links = " | ".join(f"[{i + 1} ⬇️](#^{child.id})" for i, child in enumerate(node.children_nodes))
150
+ links = " | ".join(
151
+ f"[{i + 1} ⬇️](#^{shorten_id(child.id)})" for i, child in enumerate(node.children_nodes)
152
+ )
133
153
  return f"\n{links}\n"
134
154
 
135
155
 
@@ -138,7 +158,7 @@ def render_node(
138
158
  headers: AuthorHeaders,
139
159
  use_dollar_latex: bool = False,
140
160
  asset_resolver: Callable[[str], str | None] | None = None,
141
- flavor: str = "obsidian",
161
+ flavor: str = "standard",
142
162
  ) -> str:
143
163
  """Render a complete node as markdown.
144
164
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: convoviz
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Get analytics and visualizations on your ChatGPT data!
5
5
  Keywords: markdown,chatgpt,openai,visualization,analytics,json,export,data-analysis,obsidian
6
6
  Author: Mohamed Cheikh Sidiya
@@ -101,7 +101,19 @@ convoviz --help
101
101
 
102
102
  ### 4. Check the Output 🎉
103
103
 
104
- And that's it! After running the script, head over to the output folder to see your nice word clouds, graphs, and neatly formatted Markdown files. Enjoy !
104
+ And that's it! After running the script, head over to the output folder to see your neatly formatted Markdown files and visualizations.
105
+
106
+ The main outputs are:
107
+
108
+ - **`Markdown/`**: one `.md` file per conversation
109
+ - **`Graphs/`**: a small set of high-signal plots, including:
110
+ - `overview.png` (dashboard)
111
+ - `activity_heatmap.png` (weekday × hour)
112
+ - `daily_activity.png` / `monthly_activity.png`
113
+ - `model_usage.png`, `conversation_lengths.png`
114
+ - `weekday_pattern.png`, `hourly_pattern.png`, `conversation_lifetimes.png`
115
+ - **`Word-Clouds/`**: weekly/monthly/yearly word clouds
116
+ - **`custom_instructions.json`**: extracted custom instructions
105
117
 
106
118
  ## Share Your Feedback! 💌
107
119
 
@@ -119,7 +131,7 @@ And if you've had a great experience, consider giving the project a star ⭐. It
119
131
 
120
132
  ## Notes
121
133
 
122
- This is just a small thing I coded to help me see my convos in beautiful markdown, in [Obsidian](https://obsidian.md/) (my go-to note-taking app).
134
+ This is just a small thing I coded to help me see my convos in beautiful markdown. It was originally built with [Obsidian](https://obsidian.md/) (my go-to note-taking app) in mind, but the default output is standard Markdown (and you can choose an Obsidian-flavored mode in the interactive config if you want block IDs / navigation links).
123
135
 
124
136
  I wasn't a fan of the clunky, and sometimes paid, browser extensions.
125
137
 
@@ -1,7 +1,7 @@
1
1
  convoviz/__init__.py,sha256=bQLCHO2U9EyMTGqNgsYiCtBQKTKNj4iIM3-TwIkrnRY,612
2
2
  convoviz/__main__.py,sha256=1qiGW13_SgL7wJi8iioIN-AAHGkNGnEl5q_RcPUrI0s,143
3
3
  convoviz/analysis/__init__.py,sha256=FxgH5JJpyypiLJpMQn_HlM51jnb8lQdP63_C_W3Dlx4,241
4
- convoviz/analysis/graphs.py,sha256=3CV4yhFwfUYb5-CXtq4D-r_vf0jn5cxDXwaPu1P8M8g,14928
4
+ convoviz/analysis/graphs.py,sha256=gt056UkgGcy9vCkupQmW_HjOLy-W6j4Ekxr315BXPgA,29457
5
5
  convoviz/analysis/wordcloud.py,sha256=ZnbA_-rcXHwXIny_xbudfJDQbIuPT7urNFfHcx6QWxQ,4673
6
6
  convoviz/assets/colormaps.txt,sha256=59TSGz428AxY14AEvymAH2IJ2RT9Mlp7Sy0N12NEdXQ,108
7
7
  convoviz/assets/fonts/AmaticSC-Regular.ttf,sha256=83clh7a3urnTLud0_yZofuIb6BdyC2LMI9jhE6G2LvU,142696
@@ -37,25 +37,25 @@ convoviz/assets/fonts/YsabeauSC-Thin.ttf,sha256=hZGOZNTRrxbiUPE2VDeLbtnaRwkMOBaV
37
37
  convoviz/assets/fonts/Zeyada-Regular.ttf,sha256=fKhkrp9VHt_3Aw8JfkfkPeC2j3CilLWuPUudzBeawPQ,57468
38
38
  convoviz/assets/stopwords.txt,sha256=7_ywpxsKYOj3U5CZTh9lP4GqbbkZLMabSOjKAXFk6Wc,539
39
39
  convoviz/cli.py,sha256=8HNn-6kmDN8ECb0BspvjeGa_636SQPDffpM0yINgNII,3463
40
- convoviz/config.py,sha256=EbkMl5DNcExJiUSVB8Yg1cftpduMp45-Qabg6DBFoKQ,2724
40
+ convoviz/config.py,sha256=UUynLSD22e5_fdm2zXx_bjRNOPRov_UjMBIY8u-76vg,2815
41
41
  convoviz/exceptions.py,sha256=bQpIKls48uOQpagEJAxpXf5LF7QoagRRfbD0MjWC7Ak,1476
42
- convoviz/interactive.py,sha256=hnla88hUqRjN-YV6zcauohMwxgQwbV3Y0UMT-FfXEMw,6350
42
+ convoviz/interactive.py,sha256=VXtKgYo9tZGtsoj7zThdnbTrbjSNP5MzAZbdOs3icW4,7424
43
43
  convoviz/io/__init__.py,sha256=y70TYypJ36_kaEA04E2wa1EDaKQVjprKItoKR6MMs4M,471
44
44
  convoviz/io/assets.py,sha256=BykidWJG6OQAgbVfUByQ3RLTrldzpZ_NeM7HV3a5Tig,2333
45
45
  convoviz/io/loaders.py,sha256=RuGiGzpyNcgwTxOM-m2ehhyh2mP1-k1YamK8-VynR3g,5713
46
46
  convoviz/io/writers.py,sha256=KaLr0f2F2Pw5XOoQKMA75IeQYXUTT4WbS-HAqRxsp3c,3494
47
47
  convoviz/models/__init__.py,sha256=6gAfrk6KJT2QxdvX_v15mUdfIqEw1xKxwQlKSfyA5eI,532
48
48
  convoviz/models/collection.py,sha256=L658yKMNC6IZrfxYxZBe-oO9COP_bzVfRznnNon7tfU,4467
49
- convoviz/models/conversation.py,sha256=5Xw1po0N92AdgpnbwFd6Ukb_io34OzSfDGeYDwyuPDk,5123
49
+ convoviz/models/conversation.py,sha256=ssx1Z6LM9kJIx3OucQW8JVoAc8zCdxj1iOLtie2B3ak,5678
50
50
  convoviz/models/message.py,sha256=mVnaUG6hypz92Oz3OgFAK1uuTgH3ZOJAWsFiCpLYneY,5459
51
51
  convoviz/models/node.py,sha256=1vBAtKVscYsUBDnKAOyLxuZaK9KoVF1dFXiKXRHxUnY,1946
52
52
  convoviz/pipeline.py,sha256=Mwg3Xqazk5PrsIHxhVajtWbfq4PgFlIGVHWq8BsW0U0,5750
53
53
  convoviz/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
54
54
  convoviz/renderers/__init__.py,sha256=IQgwD9NqtUgbS9zwyPBNZbBIZcFrbZ9C7WMAV-X3Xdg,261
55
- convoviz/renderers/markdown.py,sha256=kBeHqDH8yEiVN0N03dUUSJ-JbmdRmdoiC863NI83gXo,7211
55
+ convoviz/renderers/markdown.py,sha256=mpDt-xrjsPX_wt9URCDk2wicesaVv_VTWWxTHCMKiLM,7765
56
56
  convoviz/renderers/yaml.py,sha256=XG1s4VhDdx-TiqekTkgED87RZ1lVQ7IwrbA-sZHrs7k,4056
57
57
  convoviz/utils.py,sha256=IQEKYHhWOnYxlr4GwAHoquG0BXTlVRkORL80oUSaIeQ,3417
58
- convoviz-0.2.4.dist-info/WHEEL,sha256=eycQt0QpYmJMLKpE3X9iDk8R04v2ZF0x82ogq-zP6bQ,79
59
- convoviz-0.2.4.dist-info/entry_points.txt,sha256=HYsmsw5vt36yYHB05uVU48AK2WLkcwshly7m7KKuZMY,54
60
- convoviz-0.2.4.dist-info/METADATA,sha256=DuwAsh5Sei0B-5Q-cMCzcXH0bUO9ZyzvbAfoV8Ury2M,5309
61
- convoviz-0.2.4.dist-info/RECORD,,
58
+ convoviz-0.2.6.dist-info/WHEEL,sha256=eycQt0QpYmJMLKpE3X9iDk8R04v2ZF0x82ogq-zP6bQ,79
59
+ convoviz-0.2.6.dist-info/entry_points.txt,sha256=HYsmsw5vt36yYHB05uVU48AK2WLkcwshly7m7KKuZMY,54
60
+ convoviz-0.2.6.dist-info/METADATA,sha256=8bNTXriUg1k45-hM3NlxNrk01HM1Hu8xZPLhtk8uYgI,5994
61
+ convoviz-0.2.6.dist-info/RECORD,,