convoviz 0.2.4__py3-none-any.whl → 0.2.5__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):
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:
@@ -81,7 +81,7 @@ def render_message_header(role: str, headers: AuthorHeaders) -> str:
81
81
  return header_map.get(role, f"### {role.title()}")
82
82
 
83
83
 
84
- def render_node_header(node: Node, headers: AuthorHeaders, flavor: str = "obsidian") -> str:
84
+ def render_node_header(node: Node, headers: AuthorHeaders, flavor: str = "standard") -> str:
85
85
  """Render the header section of a node.
86
86
 
87
87
  Includes the node ID, parent link, and message author header.
@@ -113,7 +113,7 @@ def render_node_header(node: Node, headers: AuthorHeaders, flavor: str = "obsidi
113
113
  return "\n".join(parts) + "\n"
114
114
 
115
115
 
116
- def render_node_footer(node: Node, flavor: str = "obsidian") -> str:
116
+ def render_node_footer(node: Node, flavor: str = "standard") -> str:
117
117
  """Render the footer section of a node with child links.
118
118
 
119
119
  Args:
@@ -138,7 +138,7 @@ def render_node(
138
138
  headers: AuthorHeaders,
139
139
  use_dollar_latex: bool = False,
140
140
  asset_resolver: Callable[[str], str | None] | None = None,
141
- flavor: str = "obsidian",
141
+ flavor: str = "standard",
142
142
  ) -> str:
143
143
  """Render a complete node as markdown.
144
144
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: convoviz
3
- Version: 0.2.4
3
+ Version: 0.2.5
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
@@ -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=vjedCcpQ_t-mR6cZ4GJJuyRPDeY95XCIiMXufVIlm9M,2724
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=HDvTYpTJUI87o8QjS5ZfMS1FLRS4zPNBvCDyWzEpi9o,7211
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.5.dist-info/WHEEL,sha256=eycQt0QpYmJMLKpE3X9iDk8R04v2ZF0x82ogq-zP6bQ,79
59
+ convoviz-0.2.5.dist-info/entry_points.txt,sha256=HYsmsw5vt36yYHB05uVU48AK2WLkcwshly7m7KKuZMY,54
60
+ convoviz-0.2.5.dist-info/METADATA,sha256=nh8J1XdXD9CdGO3REyBLZTdan-LdCP92tofpY7w4Wt0,5309
61
+ convoviz-0.2.5.dist-info/RECORD,,