kash-shell 0.3.28__py3-none-any.whl → 0.3.33__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 (74) hide show
  1. kash/actions/core/chat.py +1 -0
  2. kash/actions/core/markdownify_html.py +4 -5
  3. kash/actions/core/minify_html.py +4 -5
  4. kash/actions/core/readability.py +1 -4
  5. kash/actions/core/render_as_html.py +10 -7
  6. kash/actions/core/save_sidematter_meta.py +47 -0
  7. kash/actions/core/show_webpage.py +2 -0
  8. kash/actions/core/zip_sidematter.py +47 -0
  9. kash/commands/base/basic_file_commands.py +7 -4
  10. kash/commands/base/diff_commands.py +6 -4
  11. kash/commands/base/files_command.py +31 -30
  12. kash/commands/base/general_commands.py +3 -2
  13. kash/commands/base/logs_commands.py +6 -4
  14. kash/commands/base/reformat_command.py +3 -2
  15. kash/commands/base/search_command.py +4 -3
  16. kash/commands/base/show_command.py +9 -7
  17. kash/commands/help/assistant_commands.py +6 -4
  18. kash/commands/help/help_commands.py +7 -4
  19. kash/commands/workspace/selection_commands.py +18 -16
  20. kash/commands/workspace/workspace_commands.py +39 -26
  21. kash/config/logger.py +1 -1
  22. kash/config/setup.py +2 -27
  23. kash/config/text_styles.py +1 -1
  24. kash/docs/markdown/topics/a1_what_is_kash.md +26 -18
  25. kash/docs/markdown/topics/a2_installation.md +3 -2
  26. kash/exec/action_decorators.py +7 -5
  27. kash/exec/action_exec.py +104 -53
  28. kash/exec/fetch_url_items.py +40 -11
  29. kash/exec/llm_transforms.py +14 -5
  30. kash/exec/preconditions.py +2 -2
  31. kash/exec/resolve_args.py +4 -1
  32. kash/exec/runtime_settings.py +3 -0
  33. kash/file_storage/file_store.py +108 -114
  34. kash/file_storage/item_file_format.py +91 -26
  35. kash/file_storage/item_id_index.py +128 -0
  36. kash/help/help_types.py +1 -1
  37. kash/llm_utils/llms.py +6 -1
  38. kash/local_server/local_server_commands.py +2 -1
  39. kash/mcp/mcp_server_commands.py +3 -2
  40. kash/mcp/mcp_server_routes.py +42 -12
  41. kash/model/actions_model.py +44 -32
  42. kash/model/compound_actions_model.py +4 -3
  43. kash/model/exec_model.py +33 -3
  44. kash/model/items_model.py +150 -60
  45. kash/model/params_model.py +4 -4
  46. kash/shell/output/shell_output.py +1 -2
  47. kash/utils/api_utils/gather_limited.py +2 -0
  48. kash/utils/api_utils/multitask_gather.py +74 -0
  49. kash/utils/common/s3_utils.py +108 -0
  50. kash/utils/common/url.py +16 -4
  51. kash/utils/file_formats/chat_format.py +7 -4
  52. kash/utils/file_utils/file_ext.py +1 -0
  53. kash/utils/file_utils/file_formats.py +4 -2
  54. kash/utils/file_utils/file_formats_model.py +12 -0
  55. kash/utils/text_handling/doc_normalization.py +1 -1
  56. kash/utils/text_handling/markdown_footnotes.py +224 -0
  57. kash/utils/text_handling/markdown_utils.py +532 -41
  58. kash/utils/text_handling/markdownify_utils.py +2 -1
  59. kash/web_content/web_fetch.py +2 -1
  60. kash/web_gen/templates/components/tooltip_scripts.js.jinja +186 -1
  61. kash/web_gen/templates/components/youtube_popover_scripts.js.jinja +223 -0
  62. kash/web_gen/templates/components/youtube_popover_styles.css.jinja +150 -0
  63. kash/web_gen/templates/content_styles.css.jinja +53 -1
  64. kash/web_gen/templates/youtube_webpage.html.jinja +47 -0
  65. kash/web_gen/webpage_render.py +103 -0
  66. kash/workspaces/workspaces.py +0 -5
  67. kash/xonsh_custom/custom_shell.py +4 -3
  68. {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/METADATA +35 -26
  69. {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/RECORD +72 -64
  70. kash/llm_utils/llm_features.py +0 -72
  71. kash/web_gen/simple_webpage.py +0 -55
  72. {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/WHEEL +0 -0
  73. {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/entry_points.txt +0 -0
  74. {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/licenses/LICENSE +0 -0
@@ -100,8 +100,9 @@ def cache_list(media: bool = False, content: bool = False, workspace: str | None
100
100
  """
101
101
  List the contents of the workspace media and content caches. By default lists both caches.
102
102
 
103
- :param media: List media cache only.
104
- :param content: List content cache only.
103
+ Args:
104
+ media: List media cache only.
105
+ content: List content cache only.
105
106
  """
106
107
  if not media and not content:
107
108
  media = True
@@ -131,9 +132,10 @@ def clear_cache(media: bool = False, content: bool = False, workspace: str | Non
131
132
  """
132
133
  Clear the media and content caches. By default clears both caches.
133
134
 
134
- :param media: Clear media cache only.
135
- :param content: Clear content cache only.
136
- :param global_cache: If in a workspace, clear the global caches, not the workspace caches.
135
+ Args:
136
+ media: Clear media cache only.
137
+ content: Clear content cache only.
138
+ global_cache: If in a workspace, clear the global caches, not the workspace caches.
137
139
  """
138
140
  if not media and not content:
139
141
  media = True
@@ -193,8 +195,9 @@ def history(max: int = 30, raw: bool = False) -> None:
193
195
 
194
196
  For xonsh's built-in history, use `xhistory`.
195
197
 
196
- :param max: Show at most the last `max` commands.
197
- :param raw: Show raw command history by tailing the history file directly.
198
+ Args:
199
+ max: Show at most the last `max` commands.
200
+ raw: Show raw command history by tailing the history file directly.
198
201
  """
199
202
  # TODO: Customize this by time frame.
200
203
  ws = current_ws()
@@ -395,15 +398,21 @@ def params(full: bool = False) -> None:
395
398
 
396
399
  @kash_command
397
400
  def import_item(
398
- *files_or_urls: str, type: ItemType | None = None, inplace: bool = False
401
+ *files_or_urls: str,
402
+ type: ItemType | None = None,
403
+ inplace: bool = False,
404
+ with_sidematter: bool = False,
399
405
  ) -> ShellResult:
400
406
  """
401
407
  Add a file or URL resource to the workspace as an item.
402
408
 
403
- :param inplace: If set and the item is already in the store, reimport the item,
404
- adding or rewriting metadata frontmatter.
405
- :param type: Change the item type. Usually items are auto-detected from the file
406
- format (typically doc or resource), but you can override this with this option.
409
+ Args:
410
+ inplace: If set and the item is already in the store, reimport the item,
411
+ adding or rewriting metadata frontmatter.
412
+ type: Change the item type. Usually items are auto-detected from the file
413
+ format (typically doc or resource), but you can override this with this option.
414
+ with_sidematter: If set, will copy any sidematter-format files (metadata/assets)
415
+ to the destination.
407
416
  """
408
417
  if not files_or_urls:
409
418
  raise InvalidInput("No files or URLs provided")
@@ -412,7 +421,9 @@ def import_item(
412
421
  store_paths = []
413
422
 
414
423
  locators = [resolve_locator_arg(r) for r in files_or_urls]
415
- store_paths = ws.import_items(*locators, as_type=type, reimport=inplace)
424
+ store_paths = ws.import_items(
425
+ *locators, as_type=type, reimport=inplace, with_sidematter=with_sidematter
426
+ )
416
427
 
417
428
  print_status(
418
429
  "Imported %s %s:\n%s",
@@ -434,9 +445,10 @@ def save_clipboard(
434
445
  """
435
446
  Import the contents of the OS-native clipboard as a new item in the workspace.
436
447
 
437
- :param title: The title of the new item (default: "pasted_text").
438
- :param type: The type of the new item (default: resource).
439
- :param format: The format of the new item (default: plaintext).
448
+ Args:
449
+ title: The title of the new item (default: "pasted_text").
450
+ type: The type of the new item (default: resource).
451
+ format: The format of the new item (default: plaintext).
440
452
  """
441
453
  import pyperclip
442
454
 
@@ -537,8 +549,9 @@ def applicable_actions(*paths: str, brief: bool = False, all: bool = False) -> N
537
549
  Show the actions that are applicable to the current selection.
538
550
  This is a great command to use at any point to see what actions are available!
539
551
 
540
- :param brief: Show only action names. Otherwise show actions and descriptions.
541
- :param all: Include actions with no preconditions.
552
+ Args:
553
+ brief: Show only action names. Otherwise show actions and descriptions.
554
+ all: Include actions with no preconditions.
542
555
  """
543
556
  store_paths = assemble_store_path_args(*paths)
544
557
  ws = current_ws()
@@ -592,19 +605,19 @@ def applicable_actions(*paths: str, brief: bool = False, all: bool = False) -> N
592
605
 
593
606
 
594
607
  @kash_command
595
- def preconditions() -> None:
608
+ def preconditions(path: str | None = None) -> None:
596
609
  """
597
- List all preconditions and if the current selection meets them.
610
+ List all preconditions and if the current selection or specified path meets them.
598
611
  """
599
612
 
600
613
  ws = current_ws()
601
- selection = ws.selections.current.paths
602
- if not selection:
603
- raise InvalidInput("No selection")
604
-
605
- items = [ws.load(item) for item in selection]
614
+ input_paths = assemble_path_args(path)
615
+ items = [ws.load(item) for item in input_paths]
606
616
 
607
- print_status("Precondition check for selection:\n %s", fmt_lines(selection))
617
+ if path:
618
+ print_status("Precondition check for path:\n%s", fmt_lines([fmt_loc(path)]))
619
+ else:
620
+ print_status("Precondition check for selection:\n%s", fmt_lines(input_paths))
608
621
 
609
622
  for precondition in get_all_preconditions().values():
610
623
  satisfied = all(precondition(item) for item in items)
kash/config/logger.py CHANGED
@@ -281,7 +281,7 @@ def _do_logging_setup(log_settings: LogSettings):
281
281
  def prefix(line: str, emoji: str = "", warn_emoji: str = "") -> str:
282
282
  prefix = task_stack_prefix_str()
283
283
  emojis = f"{warn_emoji}{emoji}".strip()
284
- return " ".join(filter(None, [prefix, emojis, line]))
284
+ return "".join(filter(None, [prefix, emojis, line]))
285
285
 
286
286
 
287
287
  def prefix_args(
kash/config/setup.py CHANGED
@@ -1,7 +1,5 @@
1
- from enum import Enum
2
1
  from functools import cache
3
2
  from pathlib import Path
4
- from typing import Any
5
3
 
6
4
  from clideps.env_vars.dotenv_utils import load_dotenv_paths
7
5
 
@@ -77,29 +75,6 @@ def kash_setup(
77
75
 
78
76
 
79
77
  def _lib_setup():
80
- from frontmatter_format.yaml_util import add_default_yaml_customizer
81
- from ruamel.yaml import Representer
78
+ from sidematter_format import register_default_yaml_representers
82
79
 
83
- def represent_enum(dumper: Representer, data: Enum) -> Any:
84
- """
85
- Represent Enums as their values.
86
- Helps make it easy to serialize enums to YAML everywhere.
87
- We use the convention of storing enum values as readable strings.
88
- """
89
- return dumper.represent_str(data.value)
90
-
91
- add_default_yaml_customizer(
92
- lambda yaml: yaml.representer.add_multi_representer(Enum, represent_enum)
93
- )
94
-
95
- # Maybe useful?
96
-
97
- # from pydantic import BaseModel
98
-
99
- # def represent_pydantic(dumper: Representer, data: BaseModel) -> Any:
100
- # """Represent Pydantic models as YAML dictionaries."""
101
- # return dumper.represent_dict(data.model_dump())
102
-
103
- # add_default_yaml_customizer(
104
- # lambda yaml: yaml.representer.add_multi_representer(BaseModel, represent_pydantic)
105
- # )
80
+ register_default_yaml_representers()
@@ -262,7 +262,7 @@ PROMPT_ASSIST = "(assistant) ❯"
262
262
 
263
263
  EMOJI_HINT = "👉"
264
264
 
265
- EMOJI_MSG_INDENT = "⋮"
265
+ EMOJI_MSG_INDENT = "⋮ "
266
266
 
267
267
  EMOJI_START = "[➤]"
268
268
 
@@ -1,3 +1,16 @@
1
+ ## Hello!
2
+
3
+ If you’re seeing this, you there’s a good chance I shared it with you for feedback.
4
+ Thank you for checking out Kash.
5
+
6
+ It’s new, the result of some experimentation over the past few months.
7
+ I like a lot of things about it but it isn’t mature and I’d love your help to make it
8
+ more usable. If you try it please **let me know** what works and what doesn’t work.
9
+ Or if you just don’t get it, where you lost interest or got stuck.
10
+ My contact info is at [github.com/jlevy](https://github.com/jlevy) or [follow or DM
11
+ me](https://x.com/ojoshe) (I’m fastest on Twitter DMs).
12
+ Thank you. :)
13
+
1
14
  ## What is Kash?
2
15
 
3
16
  > “*Simple should be simple.
@@ -12,9 +25,15 @@ It operates on “items” such as URLs, files, or Markdown notes within a works
12
25
  directory.
13
26
 
14
27
  You can use Kash as an **interactive, AI-native command-line** shell for practical
15
- knowledge tasks. It’s also **a Python library** that lets you convert a simple Python
16
- function into a command and an MCP tool, so it integrates with other tools like
17
- Anthropic Desktop or Cursor.
28
+ knowledge tasks.
29
+
30
+ But it’s actually not just a shell, and you can skip the shell entirely.
31
+ It’s really simply **a Python library** that lets you convert a simple Python function
32
+ into “actions” that work in a clean way on plain files in a workspace.
33
+ An action is also an MCP tool, so it integrates with other tools like Anthropic Desktop
34
+ or Cursor.
35
+
36
+ So basically, it gives a unified way to use the shell, Python functions, and MCP tools.
18
37
 
19
38
  It’s new and still has some rough edges, but it’s now working well enough it is feeling
20
39
  quite powerful. It now serves as a replacement for my usual shell (previously bash or
@@ -73,10 +92,10 @@ quick to install via uv.
73
92
  - **Support for any API:** Kash is tool agnostic and runs locally, on file inputs in
74
93
  simple formats, so you own and manage your data and workspaces however you like.
75
94
  You can use it with any models or APIs you like, and is already set up to use the APIs
76
- of **OpenAI GPT-4o and o1**, **Anthropic Claude 3.7**, **Google Gemini**, **xAI
77
- Grok**, **Mistral**, **Groq (Llama, Qwen, Deepseek)** (via **LiteLLM**), **Deepgram**,
78
- **Perplexity**, **Firecrawl**, **Exa**, and any Python libraries.
79
- There is also some experimental support for **LlamaIndex** and **ChromaDB**.
95
+ of **OpenAI** (GPT-5 is now the default model), **Anthropic Claude**, **Google
96
+ Gemini**, **xAI Grok**, **Mistral**, **Groq (Llama, Qwen, Deepseek)** (via
97
+ **LiteLLM**), **Deepgram**, **Perplexity**, **Firecrawl**, **Exa**, and any Python
98
+ libraries. There is also some experimental support for **LlamaIndex** and **ChromaDB**.
80
99
 
81
100
  - **MCP support:** Finally, an action is also an **MCP tool server** so you can use it
82
101
  in any MCP client, like Anthropic Desktop or Cursor.
@@ -110,14 +129,3 @@ I’ve separately built a new desktop terminal app, Kerm, which adds support for
110
129
  “Kerm codes” protocol for such visual components, encoded as OSC codes then rendered in
111
130
  the terminal. Because Kash supports these codes, as this develops you will get the
112
131
  visuals of a web app layered on the flexibility of a text-based terminal.
113
-
114
- ### Is Kash Mature?
115
-
116
- It’s the result of a couple months of coding and experimentation, and it’s still in
117
- progress and has rough edges.
118
- Please help me make it better by sharing your ideas and feedback!
119
- It’s easiest to DM me at [twitter.com/ojoshe](https://x.com/ojoshe).
120
- My contact info is at [github.com/jlevy](https://github.com/jlevy).
121
-
122
- [**Please follow or DM me**](https://x.com/ojoshe) for future updates or if you have
123
- ideas, feedback, or use cases for Kash!
@@ -60,8 +60,9 @@ These are for `kash-media` but you can use a `kash-shell` for a more basic setup
60
60
 
61
61
  ```shell
62
62
  sudo apt-get update
63
- sudo apt-get install -y libgl1 ffmpeg libmagic-dev
64
- # For the additional command-line tools, pixi is better on Ubuntu:
63
+ sudo apt-get install -y libgl1 ffmpeg libmagic-dev imagemagick bat ripgrep hexyl
64
+
65
+ # Or for additional command-line tools, pixi is better on Ubuntu:
65
66
  curl -fsSL https://pixi.sh/install.sh | sh
66
67
  . ~/.bashrc
67
68
  pixi global install ripgrep bat eza hexyl imagemagick zoxide
@@ -31,12 +31,13 @@ from kash.model.actions_model import (
31
31
  ParamSource,
32
32
  TitleTemplate,
33
33
  )
34
- from kash.model.exec_model import ExecContext
34
+ from kash.model.exec_model import ActionContext, ExecContext
35
35
  from kash.model.items_model import Item, ItemType
36
36
  from kash.model.params_model import Param, ParamDeclarations, TypedParamValues
37
37
  from kash.model.preconditions_model import Precondition
38
38
  from kash.utils.common.function_inspect import FuncParam, inspect_function_params
39
39
  from kash.utils.errors import InvalidDefinition
40
+ from kash.utils.file_utils.file_formats_model import Format
40
41
 
41
42
  log = get_logger(__name__)
42
43
 
@@ -204,6 +205,7 @@ def kash_action(
204
205
  arg_type: ArgType = ArgType.Locator,
205
206
  expected_args: ArgCount = ONE_ARG,
206
207
  output_type: ItemType = ItemType.doc,
208
+ output_format: Format | None = None,
207
209
  expected_outputs: ArgCount = ONE_ARG,
208
210
  params: ParamDeclarations = (),
209
211
  run_per_item: bool | None = None,
@@ -318,6 +320,7 @@ def kash_action(
318
320
  self.arg_type = arg_type
319
321
  self.uses_selection = uses_selection
320
322
  self.output_type = output_type
323
+ self.output_format = output_format
321
324
  self.interactive_input = interactive_input
322
325
  self.live_output = live_output
323
326
  self.mcp_tool = mcp_tool
@@ -328,7 +331,7 @@ def kash_action(
328
331
  super().__post_init__()
329
332
 
330
333
  @override
331
- def run(self, input: ActionInput, context: ExecContext) -> ActionResult:
334
+ def run(self, input: ActionInput, context: ActionContext) -> ActionResult:
332
335
  # Map the final, current actions param values back to the function parameters.
333
336
  pos_args: list[Any] = []
334
337
  kw_args: dict[str, Any] = {}
@@ -397,9 +400,8 @@ def kash_action(
397
400
  context = ExecContext(action, current_runtime_settings())
398
401
 
399
402
  # Run the action.
400
- result, _, _ = run_action_with_caching(context, action_input)
401
-
402
- return result
403
+ result_with_paths = run_action_with_caching(context, action_input)
404
+ return result_with_paths.result
403
405
 
404
406
  if is_simple_func:
405
407
  # Need to convert back to a SimpleActionFunction.
kash/exec/action_exec.py CHANGED
@@ -1,5 +1,5 @@
1
1
  import time
2
- from dataclasses import replace
2
+ from dataclasses import dataclass, replace
3
3
  from pathlib import Path
4
4
 
5
5
  from prettyfmt import fmt_lines, fmt_path, plural
@@ -23,14 +23,16 @@ from kash.model.actions_model import (
23
23
  ExecContext,
24
24
  PathOpType,
25
25
  )
26
- from kash.model.exec_model import RuntimeSettings
26
+ from kash.model.exec_model import ActionContext, RuntimeSettings
27
27
  from kash.model.items_model import Item, State
28
28
  from kash.model.operations_model import Input, Operation, Source
29
29
  from kash.model.params_model import ALL_COMMON_PARAMS, GLOBAL_PARAMS, RawParamValues
30
30
  from kash.model.paths_model import StorePath
31
31
  from kash.shell.output.shell_output import PrintHooks
32
+ from kash.utils.common.s3_utils import get_s3_parent_folder, s3_sync_to_folder
32
33
  from kash.utils.common.task_stack import task_stack
33
34
  from kash.utils.common.type_utils import not_none
35
+ from kash.utils.common.url import Url, is_s3_url
34
36
  from kash.utils.errors import ContentError, InvalidOutput, get_nonfatal_exceptions
35
37
  from kash.workspaces import Selection, current_ws
36
38
 
@@ -42,6 +44,7 @@ def prepare_action_input(*input_args: CommandArg, refetch: bool = False) -> Acti
42
44
  Prepare input args, which may be URLs or paths, into items that correspond to
43
45
  URL or file resources, either finding them in the workspace or importing them.
44
46
  Also fetches metadata for URLs if they don't already have title and description.
47
+ Automatically imports any URLs and copies any sidematter-format files (metadata/assets).
45
48
  """
46
49
  from kash.exec.fetch_url_items import fetch_url_item_content
47
50
 
@@ -49,7 +52,7 @@ def prepare_action_input(*input_args: CommandArg, refetch: bool = False) -> Acti
49
52
 
50
53
  # Ensure input items are already saved in the workspace and load the corresponding items.
51
54
  # This also imports any URLs.
52
- input_items = [ws.import_and_load(arg) for arg in input_args]
55
+ input_items = [ws.import_and_load(arg, with_sidematter=True) for arg in input_args]
53
56
 
54
57
  # URLs should have metadata like a title and be valid, so we fetch them.
55
58
  if input_items:
@@ -109,9 +112,7 @@ def log_action(action: Action, action_input: ActionInput, operation: Operation):
109
112
  log.info("Input items are:\n%s", fmt_lines(action_input.items))
110
113
 
111
114
 
112
- def check_for_existing_result(
113
- context: ExecContext, action_input: ActionInput, operation: Operation
114
- ) -> ActionResult | None:
115
+ def check_for_existing_result(context: ActionContext) -> ActionResult | None:
115
116
  """
116
117
  Check if we already have the results for this operation (same action and inputs)
117
118
  If so return it, unless rerun is requested, in which case we just log that the results
@@ -126,7 +127,7 @@ def check_for_existing_result(
126
127
 
127
128
  # Check if a previous run already produced the result.
128
129
  # To do this we preassemble outputs.
129
- preassembled_result = action.preassemble(operation, action_input)
130
+ preassembled_result = action.preassemble_result(context)
130
131
  if preassembled_result:
131
132
  # Check if these items already exist, with last_operation matching action and input fingerprints.
132
133
  already_present = [ws.find_by_id(item) for item in preassembled_result.items]
@@ -155,7 +156,7 @@ def check_for_existing_result(
155
156
 
156
157
 
157
158
  def run_action_operation(
158
- context: ExecContext,
159
+ context: ActionContext,
159
160
  action_input: ActionInput,
160
161
  operation: Operation,
161
162
  ) -> ActionResult:
@@ -183,7 +184,7 @@ def run_action_operation(
183
184
  this_op = replace(operation, arguments=[operation.arguments[i]])
184
185
  else:
185
186
  this_op = operation
186
- item.update_history(Source(operation=this_op, output_num=i, cacheable=action.cacheable))
187
+ item.update_source(Source(operation=this_op, output_num=i, cacheable=action.cacheable))
187
188
 
188
189
  # Override the state if appropriate (this handles marking items as transient).
189
190
  if settings.override_state:
@@ -205,7 +206,7 @@ class SkipItem(Exception):
205
206
  """
206
207
 
207
208
 
208
- def _run_for_each_item(context: ExecContext, input: ActionInput) -> ActionResult:
209
+ def _run_for_each_item(context: ActionContext, input: ActionInput) -> ActionResult:
209
210
  """
210
211
  Helper to process each input item. If non-fatal errors are encountered on any item,
211
212
  they are reported and processing continues with the next item.
@@ -340,69 +341,116 @@ def save_action_result(
340
341
  return result_store_paths, archived_store_paths
341
342
 
342
343
 
344
+ @dataclass(frozen=True)
345
+ class ResultWithPaths:
346
+ """
347
+ Result of an action, including the store paths of any S3 items created.
348
+ """
349
+
350
+ result: ActionResult
351
+ result_paths: list[StorePath]
352
+ archived_paths: list[StorePath]
353
+ s3_paths: list[Url]
354
+
355
+
343
356
  def run_action_with_caching(
344
- context: ExecContext, action_input: ActionInput
345
- ) -> tuple[ActionResult, list[StorePath], list[StorePath]]:
357
+ exec_context: ExecContext, action_input: ActionInput
358
+ ) -> ResultWithPaths:
346
359
  """
347
360
  Run an action, including validation, only rerunning if `rerun` requested or
348
361
  result is not already present. Returns the result, the store paths of the
349
362
  result items, and the store paths of any archived items.
350
363
 
364
+ Also handles optional S3 syncing if the input was from S3.
365
+
351
366
  Note: Mutates the input but only to add `context` to each item.
352
367
  """
353
- action = context.action
354
- settings = context.settings
368
+ action = exec_context.action
369
+ settings = exec_context.settings
355
370
  ws = settings.workspace
356
371
 
357
- # For convenience, we include the context to each item too (this helps so per-item
358
- # functions don't have to take context args everywhere).
359
- for item in action_input.items:
360
- item.context = context
372
+ # If the input is from S3, we note the parent folder to copy the output back to.
373
+ s3_parent_folder = None
374
+ if exec_context.settings.sync_to_s3 and action_input.items and action_input.items[0].url:
375
+ url = action_input.items[0].url
376
+ if url and is_s3_url(url):
377
+ s3_parent_folder = get_s3_parent_folder(url)
361
378
 
362
379
  # Assemble the operation and validate the action input.
363
- operation = validate_action_input(context, ws, action, action_input)
380
+ operation = validate_action_input(exec_context, ws, action, action_input)
364
381
 
365
382
  # Log what we're about to run.
366
383
  log_action(action, action_input, operation)
367
384
 
368
- # Check if a previous run already produced the result.
369
- existing_result = check_for_existing_result(context, action_input, operation)
385
+ # Consolidate all the context.
386
+ context = ActionContext(
387
+ exec_context=exec_context, operation=operation, action_input=action_input
388
+ )
370
389
 
371
- if existing_result and not settings.rerun:
372
- # Use the cached result.
373
- result = existing_result
374
- result_store_paths = [StorePath(not_none(item.store_path)) for item in result.items]
375
- archived_store_paths = []
390
+ try:
391
+ # Hack to add the context to each item.
392
+ # We do this before cache preassemble check.
393
+ action_input.set_context(context)
376
394
 
377
- PrintHooks.before_done_message()
378
- log.message(
379
- "%s Skipped: `%s` completed with %s %s",
380
- EMOJI_SKIP,
381
- action.name,
382
- len(result.items),
383
- plural("item", len(result.items)),
384
- )
385
- else:
386
- # Run it!
387
- result = run_action_operation(context, action_input, operation)
388
- result_store_paths, archived_store_paths = save_action_result(
389
- ws,
390
- result,
391
- action_input,
392
- as_tmp=settings.tmp_output,
393
- no_format=settings.no_format,
394
- )
395
+ # Check if a previous run already produced the result.
396
+ existing_result = check_for_existing_result(context)
395
397
 
396
- PrintHooks.before_done_message()
397
- log.message(
398
- "%s Action: `%s` completed with %s %s",
399
- EMOJI_SUCCESS,
400
- action.name,
401
- len(result.items),
402
- plural("item", len(result.items)),
398
+ if existing_result and not settings.rerun:
399
+ # Use the cached result.
400
+ result = existing_result
401
+ result_store_paths = [StorePath(not_none(item.store_path)) for item in result.items]
402
+ archived_store_paths = []
403
+
404
+ PrintHooks.before_done_message()
405
+ log.message(
406
+ "%s Skipped: `%s` completed with %s %s",
407
+ EMOJI_SKIP,
408
+ action.name,
409
+ len(result.items),
410
+ plural("item", len(result.items)),
411
+ )
412
+ else:
413
+ # Run it!
414
+ result = run_action_operation(context, action_input, operation)
415
+ result_store_paths, archived_store_paths = save_action_result(
416
+ ws,
417
+ result,
418
+ action_input,
419
+ as_tmp=settings.tmp_output,
420
+ no_format=settings.no_format,
421
+ )
422
+
423
+ PrintHooks.before_done_message()
424
+ log.message(
425
+ "%s Action: `%s` completed with %s %s",
426
+ EMOJI_SUCCESS,
427
+ action.name,
428
+ len(result.items),
429
+ plural("item", len(result.items)),
430
+ )
431
+ finally:
432
+ action_input.clear_context()
433
+
434
+ # If the action created an S3 item, we copy it back to the same S3 parent folder.
435
+ # Only do this for the first result, for simplicity.
436
+ s3_urls: list[Url] = []
437
+ if s3_parent_folder and len(result_store_paths) > 0:
438
+ log.warning(
439
+ "Source was an S3 path so syncing result S3: %s -> %s",
440
+ result_store_paths[0],
441
+ s3_parent_folder,
442
+ )
443
+ s3_urls = s3_sync_to_folder(
444
+ result_store_paths[0], s3_parent_folder, include_sidematter=True
403
445
  )
446
+ log.message("Synced result to S3:\n%s", fmt_lines(s3_urls))
404
447
 
405
- return result, result_store_paths, archived_store_paths
448
+ return ResultWithPaths(
449
+ result=result,
450
+ result_paths=result_store_paths,
451
+ archived_paths=archived_store_paths,
452
+ s3_paths=s3_urls,
453
+ )
406
454
 
407
455
 
408
456
  def run_action_with_shell_context(
@@ -480,7 +528,10 @@ def run_action_with_shell_context(
480
528
  input = prepare_action_input(*args, refetch=refetch)
481
529
 
482
530
  # Finally, run the action.
483
- result, result_store_paths, archived_store_paths = run_action_with_caching(context, input)
531
+ result_with_paths = run_action_with_caching(context, input)
532
+ result = result_with_paths.result
533
+ result_store_paths = result_with_paths.result_paths
534
+ archived_store_paths = result_with_paths.archived_paths
484
535
 
485
536
  # Implement any path operations from the output and/or select the final output
486
537
  if not internal_call: