kash-shell 0.3.28__py3-none-any.whl → 0.3.30__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.
Files changed (61) hide show
  1. kash/actions/core/markdownify_html.py +1 -4
  2. kash/actions/core/minify_html.py +4 -5
  3. kash/actions/core/render_as_html.py +9 -7
  4. kash/actions/core/save_sidematter_meta.py +47 -0
  5. kash/actions/core/zip_sidematter.py +47 -0
  6. kash/commands/base/basic_file_commands.py +7 -4
  7. kash/commands/base/diff_commands.py +6 -4
  8. kash/commands/base/files_command.py +31 -30
  9. kash/commands/base/general_commands.py +3 -2
  10. kash/commands/base/logs_commands.py +6 -4
  11. kash/commands/base/reformat_command.py +3 -2
  12. kash/commands/base/search_command.py +4 -3
  13. kash/commands/base/show_command.py +9 -7
  14. kash/commands/help/assistant_commands.py +6 -4
  15. kash/commands/help/help_commands.py +7 -4
  16. kash/commands/workspace/selection_commands.py +18 -16
  17. kash/commands/workspace/workspace_commands.py +39 -26
  18. kash/config/setup.py +2 -27
  19. kash/docs/markdown/topics/a1_what_is_kash.md +26 -18
  20. kash/exec/action_decorators.py +2 -2
  21. kash/exec/action_exec.py +56 -50
  22. kash/exec/fetch_url_items.py +36 -9
  23. kash/exec/preconditions.py +2 -2
  24. kash/exec/resolve_args.py +4 -1
  25. kash/exec/runtime_settings.py +1 -0
  26. kash/file_storage/file_store.py +59 -23
  27. kash/file_storage/item_file_format.py +91 -26
  28. kash/help/help_types.py +1 -1
  29. kash/llm_utils/llms.py +6 -1
  30. kash/local_server/local_server_commands.py +2 -1
  31. kash/mcp/mcp_server_commands.py +3 -2
  32. kash/mcp/mcp_server_routes.py +1 -1
  33. kash/model/actions_model.py +31 -30
  34. kash/model/compound_actions_model.py +4 -3
  35. kash/model/exec_model.py +30 -3
  36. kash/model/items_model.py +114 -57
  37. kash/model/params_model.py +4 -4
  38. kash/shell/output/shell_output.py +1 -2
  39. kash/utils/file_formats/chat_format.py +7 -4
  40. kash/utils/file_utils/file_ext.py +1 -0
  41. kash/utils/file_utils/file_formats.py +4 -2
  42. kash/utils/file_utils/file_formats_model.py +12 -0
  43. kash/utils/text_handling/doc_normalization.py +1 -1
  44. kash/utils/text_handling/markdown_footnotes.py +224 -0
  45. kash/utils/text_handling/markdown_utils.py +532 -41
  46. kash/utils/text_handling/markdownify_utils.py +2 -1
  47. kash/web_gen/templates/components/tooltip_scripts.js.jinja +186 -1
  48. kash/web_gen/templates/components/youtube_popover_scripts.js.jinja +223 -0
  49. kash/web_gen/templates/components/youtube_popover_styles.css.jinja +150 -0
  50. kash/web_gen/templates/content_styles.css.jinja +53 -1
  51. kash/web_gen/templates/youtube_webpage.html.jinja +47 -0
  52. kash/web_gen/webpage_render.py +103 -0
  53. kash/workspaces/workspaces.py +0 -5
  54. kash/xonsh_custom/custom_shell.py +4 -3
  55. {kash_shell-0.3.28.dist-info → kash_shell-0.3.30.dist-info}/METADATA +33 -24
  56. {kash_shell-0.3.28.dist-info → kash_shell-0.3.30.dist-info}/RECORD +59 -54
  57. kash/llm_utils/llm_features.py +0 -72
  58. kash/web_gen/simple_webpage.py +0 -55
  59. {kash_shell-0.3.28.dist-info → kash_shell-0.3.30.dist-info}/WHEEL +0 -0
  60. {kash_shell-0.3.28.dist-info → kash_shell-0.3.30.dist-info}/entry_points.txt +0 -0
  61. {kash_shell-0.3.28.dist-info → kash_shell-0.3.30.dist-info}/licenses/LICENSE +0 -0
kash/model/items_model.py CHANGED
@@ -17,7 +17,7 @@ from prettyfmt import (
17
17
  slugify_snake,
18
18
  )
19
19
  from pydantic.dataclasses import dataclass
20
- from strif import abbrev_str, format_iso_timestamp
20
+ from strif import abbrev_str, format_iso_timestamp, single_line
21
21
 
22
22
  from kash.config.logger import get_logger
23
23
  from kash.model.concept_model import canonicalize_concept
@@ -34,7 +34,9 @@ from kash.utils.text_handling.markdown_render import markdown_to_html
34
34
  from kash.utils.text_handling.markdown_utils import first_heading
35
35
 
36
36
  if TYPE_CHECKING:
37
- from kash.model.exec_model import ExecContext
37
+ from sidematter_format import ResolvedSidematter
38
+
39
+ from kash.model.exec_model import ActionContext
38
40
  from kash.workspaces import Workspace
39
41
 
40
42
  log = get_logger(__name__)
@@ -68,7 +70,15 @@ class ItemType(Enum):
68
70
  """
69
71
  Resources don't have a body. On concepts it's optional.
70
72
  """
71
- return self.value not in [ItemType.resource.value, ItemType.concept.value]
73
+ return self.value not in (ItemType.resource.value, ItemType.concept.value)
74
+
75
+ @property
76
+ def allows_op_suffix(self) -> bool:
77
+ """
78
+ Whether it makes sense to have an operation suffix for this item type
79
+ (docs often should, but concepts or resources can have cleaner naming conventions).
80
+ """
81
+ return self not in (ItemType.concept, ItemType.resource, ItemType.export)
72
82
 
73
83
  @staticmethod
74
84
  def for_format(format: Format) -> ItemType:
@@ -100,6 +110,7 @@ class ItemType(Enum):
100
110
  Format.mp3: ItemType.resource,
101
111
  Format.m4a: ItemType.resource,
102
112
  Format.mp4: ItemType.resource,
113
+ Format.zip: ItemType.resource,
103
114
  }
104
115
  return format_to_item_type.get(format, ItemType.resource)
105
116
 
@@ -267,7 +278,7 @@ class Item:
267
278
 
268
279
  # Optional execution context. Useful for letting functions that take only an Item
269
280
  # arg get access to context.
270
- context: ExecContext | None = field(default=None, metadata={"exclude": True})
281
+ context: ActionContext | None = field(default=None, metadata={"exclude": True})
271
282
 
272
283
  # These fields we don't want in YAML frontmatter.
273
284
  # We don't include store_path as it's redundant with the filename.
@@ -369,9 +380,9 @@ class Item:
369
380
  item_type: ItemType | None = None,
370
381
  *,
371
382
  title: str | None = None,
372
- original_filename: str | None = None,
373
383
  url: Url | None = None,
374
384
  mime_type: MimeType | None = None,
385
+ preserve_filename: bool = True,
375
386
  ) -> Item:
376
387
  """
377
388
  Create a resource Item for a file with a format inferred from the file extension
@@ -400,9 +411,10 @@ class Item:
400
411
  if not file_ext:
401
412
  file_ext = format_info.suggested_file_ext
402
413
 
414
+ original_filename = Path(path).name if preserve_filename else None
403
415
  item = cls(
404
416
  type=item_type,
405
- title=title,
417
+ title=single_line(title) if title else None, # Avoid multiline titles.
406
418
  file_ext=file_ext,
407
419
  format=format,
408
420
  external_path=str(path),
@@ -428,7 +440,7 @@ class Item:
428
440
  return cls(
429
441
  type=ItemType.resource,
430
442
  format=Format.url,
431
- title=media_metadata.title,
443
+ title=single_line(media_metadata.title), # Avoid multiline titles.
432
444
  url=media_metadata.url,
433
445
  description=media_metadata.description,
434
446
  thumbnail_url=media_metadata.thumbnail_url,
@@ -453,7 +465,7 @@ class Item:
453
465
  if self.type.expects_body and self.format.has_body and not self.body:
454
466
  raise ValueError(f"Item type `{self.type.value}` is text but has no body: {self}")
455
467
 
456
- def absolute_path(self, ws: Workspace | None = None) -> Path:
468
+ def absolute_path(self, ws: Path | Workspace | None = None) -> Path:
457
469
  """
458
470
  Get the absolute path to the item. Throws `ValueError` if the item has no
459
471
  store path. If no workspace is provided, uses the current workspace.
@@ -462,8 +474,11 @@ class Item:
462
474
 
463
475
  if not self.store_path:
464
476
  raise ValueError("Item has no store path")
465
- if not ws:
477
+ elif isinstance(ws, Path):
478
+ return ws / self.store_path
479
+ elif not ws:
466
480
  ws = current_ws()
481
+
467
482
  return ws.base_dir / self.store_path
468
483
 
469
484
  @property
@@ -485,7 +500,7 @@ class Item:
485
500
  raise ValueError("Cannot get doc id for an item that has not been saved")
486
501
  return str(self.store_path)
487
502
 
488
- def metadata(self, datetime_as_str: bool = False) -> dict[str, Any]:
503
+ def metadata(self, *, datetime_as_str: bool = False) -> dict[str, Any]:
489
504
  """
490
505
  Metadata is all relevant non-None fields in easy-to-serialize form.
491
506
  Optional fields are omitted unless they are set.
@@ -529,6 +544,15 @@ class Item:
529
544
 
530
545
  return item_dict
531
546
 
547
+ def sidematter(self, ws: Path | Workspace | None = None) -> ResolvedSidematter:
548
+ """
549
+ Get the sidematter for this item, if present, by looking at the files
550
+ in the specified workspace (or the current workspace if not specified).
551
+ """
552
+ from sidematter_format import Sidematter
553
+
554
+ return Sidematter(self.absolute_path(ws)).resolve()
555
+
532
556
  def filename_stem(self) -> str | None:
533
557
  """
534
558
  If the item has an existing or previous filename, return its stem,
@@ -545,16 +569,25 @@ class Item:
545
569
  path_name = None
546
570
  return path_name
547
571
 
548
- def slug_name(self, max_len: int = SLUG_MAX_LEN, prefer_title: bool = False) -> str:
572
+ def slug_name(
573
+ self,
574
+ max_len: int = SLUG_MAX_LEN,
575
+ prefer_title: bool = False,
576
+ add_ops_suffix: bool = True,
577
+ ) -> str:
549
578
  """
550
579
  Get a readable slugified name for this item, either from a previous filename
551
- or from slugifying the title or content. May not be unique.
580
+ or from slugifying the title or content. Adds the last operation suffix if requested
581
+ and available in item history.
552
582
  """
553
- filename_stem = self.filename_stem()
554
- if filename_stem and not prefer_title:
555
- return slugify_snake(filename_stem)
583
+ base_title = self.filename_stem()
584
+ if prefer_title or not base_title:
585
+ base_title = self.pick_title(max_len=max_len, add_ops_suffix=add_ops_suffix)
556
586
  else:
557
- return slugify_snake(self.pick_title(max_len=max_len, add_ops_suffix=True))
587
+ base_title = self.pick_title(
588
+ base_title=base_title, max_len=max_len, add_ops_suffix=add_ops_suffix
589
+ )
590
+ return slugify_snake(base_title)
558
591
 
559
592
  def default_filename(self) -> str:
560
593
  """
@@ -578,6 +611,7 @@ class Item:
578
611
 
579
612
  def pick_title(
580
613
  self,
614
+ base_title: str | None = None,
581
615
  *,
582
616
  max_len: int = 100,
583
617
  add_ops_suffix: bool = False,
@@ -590,35 +624,31 @@ class Item:
590
624
  """
591
625
  # First special case: if we are pulling the title from the body header, check
592
626
  # that.
593
- if not self.title and pull_body_heading:
594
- heading = self.body_heading()
595
- if heading:
596
- return heading
597
-
598
- # Next special case: URLs with no title use the url itself.
599
- if not self.title and self.url:
600
- return abbrev_str(self.url, max_len)
601
-
602
- filename_stem = self.filename_stem()
603
-
604
- # Use the title or the path if possible, falling back to description or even body text.
605
- title_raw_text = (
606
- self.title
607
- or filename_stem
608
- or self.description
609
- or (not self.is_binary and self.abbrev_body(max_len))
610
- or UNTITLED
611
- )
627
+ if not base_title:
628
+ if not base_title and pull_body_heading:
629
+ heading = self.body_heading()
630
+ base_title = heading
631
+
632
+ # Next special case: URLs with no title use the url itself.
633
+ if not self.title and self.url:
634
+ return abbrev_str(self.url, max_len)
635
+
636
+ filename_stem = self.filename_stem()
637
+
638
+ # Use the title or the path if possible, falling back to description or even body text.
639
+ base_title = (
640
+ self.title
641
+ or filename_stem
642
+ or self.description
643
+ or (not self.is_binary and self.abbrev_body(max_len))
644
+ or UNTITLED
645
+ )
612
646
 
613
- suffix = ""
614
647
  # For docs, etc but not for concepts/resources/exports, add a parenthical note
615
648
  # indicating the last operation, if there was one. This makes filename slugs
616
649
  # more readable.
617
- if add_ops_suffix and self.type not in [
618
- ItemType.concept,
619
- ItemType.resource,
620
- ItemType.export,
621
- ]:
650
+ suffix = ""
651
+ if add_ops_suffix and self.type.allows_op_suffix:
622
652
  last_op = self.history and self.history[-1].action_name
623
653
  if last_op:
624
654
  step_num = len(self.history) + 1 if self.history else 1
@@ -626,7 +656,7 @@ class Item:
626
656
 
627
657
  shorter_len = min(max_len, max(max_len - len(suffix), 20))
628
658
  clean_text = sanitize_title(
629
- abbrev_phrase_in_middle(html_to_plaintext(title_raw_text), shorter_len)
659
+ abbrev_phrase_in_middle(html_to_plaintext(base_title), shorter_len)
630
660
  )
631
661
 
632
662
  final_text = clean_text
@@ -670,8 +700,6 @@ class Item:
670
700
  """
671
701
  If it is a data Item, return the parsed YAML.
672
702
  """
673
- if not self.type == ItemType.data:
674
- raise FileFormatError(f"Item is not a data item: {self}")
675
703
  if not self.body:
676
704
  raise FileFormatError(f"Data item has no body: {self}")
677
705
  if self.format != Format.yaml:
@@ -795,15 +823,22 @@ class Item:
795
823
  merged_fields = self._copy_and_update(other, update_timestamp=False)
796
824
  return Item(**merged_fields)
797
825
 
798
- def derived_copy(self, **updates: Unpack[ItemUpdateOptions]) -> Item:
826
+ def derived_copy(
827
+ self,
828
+ action_context: ActionContext | None = None,
829
+ output_num: int = 0,
830
+ **updates: Unpack[ItemUpdateOptions],
831
+ ) -> Item:
799
832
  """
800
833
  Copy item with the given field updates. Resets `store_path` and `source` to None
801
834
  since those should be set explicitly later. Preserves other fields, including
802
- the body.
835
+ the type and the body.
803
836
 
804
837
  Same as `new_copy_with` but also updates the `derived_from` relation. If we also
805
838
  have an action context, then use the `title_template` to derive a new title.
806
839
  """
840
+
841
+ # Get derived_from relation if possible.
807
842
  if not self.store_path:
808
843
  if self.relations.derived_from:
809
844
  log.message(
@@ -830,30 +865,40 @@ class Item:
830
865
  assert updates["format"] is not None
831
866
  updates["file_ext"] = updates["format"].file_ext
832
867
 
833
- # External resource paths only make sense for resources, so clear them out if new item
834
- # is not a resource.
835
- new_type = updates.get("type") or self.type
836
- if "external_path" not in updates and new_type != ItemType.resource:
868
+ # External resource paths should not be preserved.
869
+ if "external_path" not in updates:
837
870
  updates["external_path"] = None
838
871
 
839
872
  new_item = self.new_copy_with(update_timestamp=True, **updates)
840
873
  if derived_from:
841
874
  new_item.update_relations(derived_from=derived_from)
842
875
 
876
+ action_context = action_context or self.context
877
+
878
+ # Record the history.
879
+ if action_context:
880
+ self.source = Source(
881
+ operation=action_context.operation,
882
+ output_num=output_num,
883
+ cacheable=action_context.action.cacheable,
884
+ )
885
+ self.add_to_history(self.source.operation.summary())
886
+ action = action_context.action
887
+ else:
888
+ action = None
889
+
843
890
  # Fall back to action title template if we have it and title wasn't explicitly set.
844
891
  if "title" not in updates:
845
892
  prev_title = self.title or (Path(self.store_path).stem if self.store_path else UNTITLED)
846
- if self.context:
847
- action = self.context.action
848
- new_item.title = action.title_template.format(
849
- title=prev_title, action_name=action.name
850
- )
893
+
894
+ if action:
895
+ new_item.title = action.format_title(prev_title)
851
896
  else:
852
- log.warning(
897
+ log.info(
853
898
  "Deriving an item without action context so keeping previous title: %s",
854
899
  self,
855
900
  )
856
- new_item.title = f"{prev_title} (derived copy)"
901
+ new_item.title = prev_title
857
902
 
858
903
  return new_item
859
904
 
@@ -906,6 +951,17 @@ class Item:
906
951
  if not self.history or self.history[-1] != operation_summary:
907
952
  self.history.append(operation_summary)
908
953
 
954
+ def mark_as_saved(self, external_path: Path) -> None:
955
+ """
956
+ Mark the item as saved at an external or internal path. If this item is saved to a
957
+ workspace and the path is inside the workspace, the save will be short-circuited.
958
+ If it's outside the workspace, the item will be copied to the workspace.
959
+ Having this method makes it quick to catch bugs where the file is missing.
960
+ """
961
+ if not external_path.exists():
962
+ raise FileNotFoundError(f"Provided path not found: {fmt_loc(external_path)}")
963
+ self.external_path = str(external_path)
964
+
909
965
  def fmt_loc(self) -> str:
910
966
  """
911
967
  Formatted store path, external path, URL, or title. Use for logging etc.
@@ -949,6 +1005,7 @@ class Item:
949
1005
  key_filter={
950
1006
  "store_path": 0,
951
1007
  "external_path": 64,
1008
+ "original_filename": 64,
952
1009
  "type": 64,
953
1010
  "format": 64,
954
1011
  "state": 64,
@@ -206,10 +206,10 @@ A list of parameter declarations, possibly with default values.
206
206
 
207
207
  # These are the default models for typical use cases.
208
208
  # The user may override them with parameters.
209
- DEFAULT_CAREFUL_LLM = LLM.o3
210
- DEFAULT_STRUCTURED_LLM = LLM.gpt_4o
211
- DEFAULT_STANDARD_LLM = LLM.claude_4_sonnet
212
- DEFAULT_FAST_LLM = LLM.gpt_4o
209
+ DEFAULT_CAREFUL_LLM = LLM.gpt_5
210
+ DEFAULT_STRUCTURED_LLM = LLM.gpt_5
211
+ DEFAULT_STANDARD_LLM = LLM.gpt_5
212
+ DEFAULT_FAST_LLM = LLM.gpt_5_mini
213
213
 
214
214
 
215
215
  # Parameters set globally such as in the workspace.
@@ -10,7 +10,6 @@ from enum import Enum, auto
10
10
  import rich
11
11
  import rich.style
12
12
  from flowmark import Wrap, fill_text
13
- from flowmark.text_filling import DEFAULT_INDENT
14
13
  from rich.console import Group, OverflowMethod, RenderableType
15
14
  from rich.rule import Rule
16
15
  from rich.style import Style
@@ -50,7 +49,7 @@ def print_style(pad_style: PadStyle):
50
49
  Context manager for print styles.
51
50
  """
52
51
  if pad_style == PadStyle.INDENT:
53
- token = print_context_var.set(DEFAULT_INDENT)
52
+ token = print_context_var.set(" ")
54
53
  try:
55
54
  yield
56
55
  finally:
@@ -93,7 +93,6 @@ content: |
93
93
 
94
94
  from __future__ import annotations
95
95
 
96
- import json
97
96
  from dataclasses import field
98
97
  from enum import Enum
99
98
  from io import StringIO
@@ -104,6 +103,7 @@ from typing import Any
104
103
  from frontmatter_format import from_yaml_string, new_yaml, to_yaml_string
105
104
  from prettyfmt import abbrev_obj, custom_key_sort, fmt_size_human
106
105
  from pydantic.dataclasses import dataclass
106
+ from sidematter_format import to_json_string
107
107
 
108
108
 
109
109
  class ChatRole(str, Enum):
@@ -161,9 +161,12 @@ class ChatMessage:
161
161
  Convert to a format that can be used as a standard chat completion, with
162
162
  the content field holding JSON-serialized data if it is structured.
163
163
  """
164
+
164
165
  return {
165
166
  "role": self.role.value,
166
- "content": json.dumps(self.content) if isinstance(self.content, dict) else self.content,
167
+ "content": to_json_string(self.content)
168
+ if isinstance(self.content, dict)
169
+ else self.content,
167
170
  }
168
171
 
169
172
  @classmethod
@@ -174,7 +177,7 @@ class ChatMessage:
174
177
  return to_yaml_string(self.as_dict(), key_sort=_custom_key_sort)
175
178
 
176
179
  def to_json(self) -> str:
177
- return json.dumps(self.as_dict())
180
+ return to_json_string(self.as_dict())
178
181
 
179
182
  def as_str(self) -> str:
180
183
  return self.to_yaml()
@@ -222,7 +225,7 @@ class ChatHistory:
222
225
  return stream.getvalue()
223
226
 
224
227
  def to_json(self) -> str:
225
- return json.dumps([message.as_dict() for message in self.messages])
228
+ return to_json_string([message.as_dict() for message in self.messages], indent=None)
226
229
 
227
230
  def size_summary(self) -> str:
228
231
  role_counts = {}
@@ -37,6 +37,7 @@ class FileExt(Enum):
37
37
  mp4 = "mp4"
38
38
  pptx = "pptx"
39
39
  epub = "epub"
40
+ zip = "zip"
40
41
 
41
42
  @property
42
43
  def dot_ext(self) -> str:
@@ -16,7 +16,7 @@ def is_fullpage_html(content: str) -> bool:
16
16
  A full HTML document that is a full page (headers, footers, etc.) and
17
17
  so probably best rendered in a browser.
18
18
  """
19
- return bool(re.search(r"<!DOCTYPE html>|<html>|<body>|<head>", content, re.IGNORECASE))
19
+ return bool(re.search(r"<!DOCTYPE html>|<html.*?>|<body>|<head>", content, re.IGNORECASE))
20
20
 
21
21
 
22
22
  _yaml_header_pattern = re.compile(r"^---\n\w+:", re.MULTILINE)
@@ -35,7 +35,9 @@ def is_html(content: str) -> bool:
35
35
  """
36
36
  return bool(
37
37
  re.search(
38
- r"<!DOCTYPE html>|<html>|<body>|<head>|<div>|<p>|<img |<a href", content, re.IGNORECASE
38
+ r"<!DOCTYPE html>|<html.*?>|<body>|<head>|<div>|<p>|<img |<a href",
39
+ content,
40
+ re.IGNORECASE,
39
41
  )
40
42
  )
41
43
 
@@ -72,6 +72,9 @@ class Format(Enum):
72
72
  mp3 = "mp3"
73
73
  m4a = "m4a"
74
74
  mp4 = "mp4"
75
+
76
+ # Binary formats.
77
+ zip = "zip"
75
78
  binary = "binary"
76
79
  """Catch-all format for binary files that are unrecognized."""
77
80
 
@@ -167,6 +170,10 @@ class Format(Enum):
167
170
  def is_data(self) -> bool:
168
171
  return self in [self.csv, self.xlsx, self.npz]
169
172
 
173
+ @property
174
+ def is_zip(self) -> bool:
175
+ return self in [self.zip]
176
+
170
177
  @property
171
178
  def is_binary(self) -> bool:
172
179
  return self.has_body and not self.is_text
@@ -257,6 +264,7 @@ class Format(Enum):
257
264
  FileExt.m4a.value: Format.m4a,
258
265
  FileExt.mp4.value: Format.mp4,
259
266
  FileExt.epub.value: Format.epub,
267
+ FileExt.zip.value: Format.zip,
260
268
  }
261
269
  return ext_to_format.get(file_ext.value, None)
262
270
 
@@ -292,6 +300,7 @@ class Format(Enum):
292
300
  Format.mp3: FileExt.mp3,
293
301
  Format.m4a: FileExt.m4a,
294
302
  Format.mp4: FileExt.mp4,
303
+ Format.zip: FileExt.zip,
295
304
  }
296
305
 
297
306
  return format_to_file_ext.get(self, None)
@@ -329,6 +338,9 @@ class Format(Enum):
329
338
  "audio/mp3": Format.mp3,
330
339
  "audio/mp4": Format.m4a,
331
340
  "video/mp4": Format.mp4,
341
+ "application/zip": Format.zip,
342
+ "application/x-zip": Format.zip,
343
+ "application/x-zip-compressed": Format.zip,
332
344
  "application/octet-stream": Format.binary,
333
345
  }
334
346
 
@@ -75,7 +75,7 @@ def normalize_text_file(
75
75
 
76
76
  def test_osc8_link():
77
77
  from clideps.terminal.osc_utils import osc8_link
78
- from flowmark.text_wrapping import wrap_paragraph
78
+ from flowmark import wrap_paragraph
79
79
 
80
80
  link = osc8_link("https://example.com/" + "x" * 50, "Example")
81
81
  assert ansi_cell_len(link) == 7