zrb 1.8.10__py3-none-any.whl → 1.21.29__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.

Potentially problematic release.


This version of zrb might be problematic. Click here for more details.

Files changed (147) hide show
  1. zrb/__init__.py +126 -113
  2. zrb/__main__.py +1 -1
  3. zrb/attr/type.py +10 -7
  4. zrb/builtin/__init__.py +2 -50
  5. zrb/builtin/git.py +12 -1
  6. zrb/builtin/group.py +31 -15
  7. zrb/builtin/http.py +7 -8
  8. zrb/builtin/llm/attachment.py +40 -0
  9. zrb/builtin/llm/chat_completion.py +274 -0
  10. zrb/builtin/llm/chat_session.py +152 -85
  11. zrb/builtin/llm/chat_session_cmd.py +288 -0
  12. zrb/builtin/llm/chat_trigger.py +79 -0
  13. zrb/builtin/llm/history.py +7 -9
  14. zrb/builtin/llm/llm_ask.py +221 -98
  15. zrb/builtin/llm/tool/api.py +74 -52
  16. zrb/builtin/llm/tool/cli.py +46 -17
  17. zrb/builtin/llm/tool/code.py +71 -90
  18. zrb/builtin/llm/tool/file.py +301 -241
  19. zrb/builtin/llm/tool/note.py +84 -0
  20. zrb/builtin/llm/tool/rag.py +38 -8
  21. zrb/builtin/llm/tool/sub_agent.py +67 -50
  22. zrb/builtin/llm/tool/web.py +146 -122
  23. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/entity/add_entity_util.py +7 -7
  24. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/add_module_util.py +5 -5
  25. zrb/builtin/project/add/fastapp/fastapp_util.py +1 -1
  26. zrb/builtin/searxng/config/settings.yml +5671 -0
  27. zrb/builtin/searxng/start.py +21 -0
  28. zrb/builtin/setup/latex/ubuntu.py +1 -0
  29. zrb/builtin/setup/ubuntu.py +1 -1
  30. zrb/builtin/shell/autocomplete/bash.py +4 -3
  31. zrb/builtin/shell/autocomplete/zsh.py +4 -3
  32. zrb/builtin/todo.py +13 -2
  33. zrb/config/config.py +614 -0
  34. zrb/config/default_prompt/file_extractor_system_prompt.md +112 -0
  35. zrb/config/default_prompt/interactive_system_prompt.md +29 -0
  36. zrb/config/default_prompt/persona.md +1 -0
  37. zrb/config/default_prompt/repo_extractor_system_prompt.md +112 -0
  38. zrb/config/default_prompt/repo_summarizer_system_prompt.md +29 -0
  39. zrb/config/default_prompt/summarization_prompt.md +57 -0
  40. zrb/config/default_prompt/system_prompt.md +38 -0
  41. zrb/config/llm_config.py +339 -0
  42. zrb/config/llm_context/config.py +166 -0
  43. zrb/config/llm_context/config_parser.py +40 -0
  44. zrb/config/llm_context/workflow.py +81 -0
  45. zrb/config/llm_rate_limitter.py +190 -0
  46. zrb/{runner → config}/web_auth_config.py +17 -22
  47. zrb/context/any_shared_context.py +17 -1
  48. zrb/context/context.py +16 -2
  49. zrb/context/shared_context.py +18 -8
  50. zrb/group/any_group.py +12 -5
  51. zrb/group/group.py +67 -3
  52. zrb/input/any_input.py +5 -1
  53. zrb/input/base_input.py +18 -6
  54. zrb/input/option_input.py +13 -1
  55. zrb/input/text_input.py +8 -25
  56. zrb/runner/cli.py +25 -23
  57. zrb/runner/common_util.py +24 -19
  58. zrb/runner/web_app.py +3 -3
  59. zrb/runner/web_route/docs_route.py +1 -1
  60. zrb/runner/web_route/error_page/serve_default_404.py +1 -1
  61. zrb/runner/web_route/error_page/show_error_page.py +1 -1
  62. zrb/runner/web_route/home_page/home_page_route.py +2 -2
  63. zrb/runner/web_route/login_api_route.py +1 -1
  64. zrb/runner/web_route/login_page/login_page_route.py +2 -2
  65. zrb/runner/web_route/logout_api_route.py +1 -1
  66. zrb/runner/web_route/logout_page/logout_page_route.py +2 -2
  67. zrb/runner/web_route/node_page/group/show_group_page.py +1 -1
  68. zrb/runner/web_route/node_page/node_page_route.py +1 -1
  69. zrb/runner/web_route/node_page/task/show_task_page.py +1 -1
  70. zrb/runner/web_route/refresh_token_api_route.py +1 -1
  71. zrb/runner/web_route/static/static_route.py +1 -1
  72. zrb/runner/web_route/task_input_api_route.py +6 -6
  73. zrb/runner/web_route/task_session_api_route.py +20 -12
  74. zrb/runner/web_util/cookie.py +1 -1
  75. zrb/runner/web_util/token.py +1 -1
  76. zrb/runner/web_util/user.py +8 -4
  77. zrb/session/any_session.py +24 -17
  78. zrb/session/session.py +50 -25
  79. zrb/session_state_logger/any_session_state_logger.py +9 -4
  80. zrb/session_state_logger/file_session_state_logger.py +16 -6
  81. zrb/session_state_logger/session_state_logger_factory.py +1 -1
  82. zrb/task/any_task.py +30 -9
  83. zrb/task/base/context.py +17 -9
  84. zrb/task/base/execution.py +15 -8
  85. zrb/task/base/lifecycle.py +8 -4
  86. zrb/task/base/monitoring.py +12 -7
  87. zrb/task/base_task.py +69 -5
  88. zrb/task/base_trigger.py +12 -5
  89. zrb/task/cmd_task.py +1 -1
  90. zrb/task/llm/agent.py +154 -161
  91. zrb/task/llm/agent_runner.py +152 -0
  92. zrb/task/llm/config.py +47 -18
  93. zrb/task/llm/conversation_history.py +209 -0
  94. zrb/task/llm/conversation_history_model.py +67 -0
  95. zrb/task/llm/default_workflow/coding/workflow.md +41 -0
  96. zrb/task/llm/default_workflow/copywriting/workflow.md +68 -0
  97. zrb/task/llm/default_workflow/git/workflow.md +118 -0
  98. zrb/task/llm/default_workflow/golang/workflow.md +128 -0
  99. zrb/task/llm/default_workflow/html-css/workflow.md +135 -0
  100. zrb/task/llm/default_workflow/java/workflow.md +146 -0
  101. zrb/task/llm/default_workflow/javascript/workflow.md +158 -0
  102. zrb/task/llm/default_workflow/python/workflow.md +160 -0
  103. zrb/task/llm/default_workflow/researching/workflow.md +153 -0
  104. zrb/task/llm/default_workflow/rust/workflow.md +162 -0
  105. zrb/task/llm/default_workflow/shell/workflow.md +299 -0
  106. zrb/task/llm/error.py +24 -10
  107. zrb/task/llm/file_replacement.py +206 -0
  108. zrb/task/llm/file_tool_model.py +57 -0
  109. zrb/task/llm/history_processor.py +206 -0
  110. zrb/task/llm/history_summarization.py +11 -166
  111. zrb/task/llm/print_node.py +193 -69
  112. zrb/task/llm/prompt.py +242 -45
  113. zrb/task/llm/subagent_conversation_history.py +41 -0
  114. zrb/task/llm/tool_wrapper.py +260 -57
  115. zrb/task/llm/workflow.py +76 -0
  116. zrb/task/llm_task.py +182 -171
  117. zrb/task/make_task.py +2 -3
  118. zrb/task/rsync_task.py +26 -11
  119. zrb/task/scheduler.py +4 -4
  120. zrb/util/attr.py +54 -39
  121. zrb/util/callable.py +23 -0
  122. zrb/util/cli/markdown.py +12 -0
  123. zrb/util/cli/text.py +30 -0
  124. zrb/util/file.py +29 -11
  125. zrb/util/git.py +8 -11
  126. zrb/util/git_diff_model.py +10 -0
  127. zrb/util/git_subtree.py +9 -14
  128. zrb/util/git_subtree_model.py +32 -0
  129. zrb/util/init_path.py +1 -1
  130. zrb/util/markdown.py +62 -0
  131. zrb/util/string/conversion.py +2 -2
  132. zrb/util/todo.py +17 -50
  133. zrb/util/todo_model.py +46 -0
  134. zrb/util/truncate.py +23 -0
  135. zrb/util/yaml.py +204 -0
  136. zrb/xcom/xcom.py +10 -0
  137. zrb-1.21.29.dist-info/METADATA +270 -0
  138. {zrb-1.8.10.dist-info → zrb-1.21.29.dist-info}/RECORD +140 -98
  139. {zrb-1.8.10.dist-info → zrb-1.21.29.dist-info}/WHEEL +1 -1
  140. zrb/config.py +0 -335
  141. zrb/llm_config.py +0 -411
  142. zrb/llm_rate_limitter.py +0 -125
  143. zrb/task/llm/context.py +0 -102
  144. zrb/task/llm/context_enrichment.py +0 -199
  145. zrb/task/llm/history.py +0 -211
  146. zrb-1.8.10.dist-info/METADATA +0 -264
  147. {zrb-1.8.10.dist-info → zrb-1.21.29.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,190 @@
1
+ import asyncio
2
+ import json
3
+ import time
4
+ from collections import deque
5
+ from typing import Any, Callable
6
+
7
+ from zrb.config.config import CFG
8
+
9
+
10
+ class LLMRateLimitter:
11
+ """
12
+ Helper class to enforce LLM API rate limits and throttling.
13
+ Tracks requests and tokens in a rolling 60-second window.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ max_requests_per_minute: int | None = None,
19
+ max_tokens_per_minute: int | None = None,
20
+ max_tokens_per_request: int | None = None,
21
+ max_tokens_per_tool_call_result: int | None = None,
22
+ throttle_sleep: float | None = None,
23
+ use_tiktoken: bool | None = None,
24
+ tiktoken_encoding_name: str | None = None,
25
+ ):
26
+ self._max_requests_per_minute = max_requests_per_minute
27
+ self._max_tokens_per_minute = max_tokens_per_minute
28
+ self._max_tokens_per_request = max_tokens_per_request
29
+ self._max_tokens_per_tool_call_result = max_tokens_per_tool_call_result
30
+ self._throttle_sleep = throttle_sleep
31
+ self._use_tiktoken = use_tiktoken
32
+ self._tiktoken_encoding_name = tiktoken_encoding_name
33
+ self.request_times = deque()
34
+ self.token_times = deque()
35
+
36
+ @property
37
+ def max_requests_per_minute(self) -> int:
38
+ if self._max_requests_per_minute is not None:
39
+ return self._max_requests_per_minute
40
+ return CFG.LLM_MAX_REQUESTS_PER_MINUTE
41
+
42
+ @property
43
+ def max_tokens_per_minute(self) -> int:
44
+ if self._max_tokens_per_minute is not None:
45
+ return self._max_tokens_per_minute
46
+ return CFG.LLM_MAX_TOKENS_PER_MINUTE
47
+
48
+ @property
49
+ def max_tokens_per_request(self) -> int:
50
+ if self._max_tokens_per_request is not None:
51
+ return self._max_tokens_per_request
52
+ return CFG.LLM_MAX_TOKENS_PER_REQUEST
53
+
54
+ @property
55
+ def max_tokens_per_tool_call_result(self) -> int:
56
+ if self._max_tokens_per_tool_call_result is not None:
57
+ return self._max_tokens_per_tool_call_result
58
+ return CFG.LLM_MAX_TOKENS_PER_TOOL_CALL_RESULT
59
+
60
+ @property
61
+ def throttle_sleep(self) -> float:
62
+ if self._throttle_sleep is not None:
63
+ return self._throttle_sleep
64
+ return CFG.LLM_THROTTLE_SLEEP
65
+
66
+ @property
67
+ def use_tiktoken(self) -> bool:
68
+ if self._use_tiktoken is not None:
69
+ return self._use_tiktoken
70
+ return CFG.USE_TIKTOKEN
71
+
72
+ @property
73
+ def tiktoken_encoding_name(self) -> str:
74
+ if self._tiktoken_encoding_name is not None:
75
+ return self._tiktoken_encoding_name
76
+ return CFG.TIKTOKEN_ENCODING_NAME
77
+
78
+ def set_max_requests_per_minute(self, value: int):
79
+ self._max_requests_per_minute = value
80
+
81
+ def set_max_tokens_per_minute(self, value: int):
82
+ self._max_tokens_per_minute = value
83
+
84
+ def set_max_tokens_per_request(self, value: int):
85
+ self._max_tokens_per_request = value
86
+
87
+ def set_max_tokens_per_tool_call_result(self, value: int):
88
+ self._max_tokens_per_tool_call_result = value
89
+
90
+ def set_throttle_sleep(self, value: float):
91
+ self._throttle_sleep = value
92
+
93
+ def count_token(self, prompt: Any) -> int:
94
+ str_prompt = self._prompt_to_str(prompt)
95
+ if not self.use_tiktoken:
96
+ return self._fallback_count_token(str_prompt)
97
+ try:
98
+ import tiktoken
99
+
100
+ enc = tiktoken.get_encoding(self.tiktoken_encoding_name)
101
+ return len(enc.encode(str_prompt))
102
+ except Exception:
103
+ return self._fallback_count_token(str_prompt)
104
+
105
+ def _fallback_count_token(self, str_prompt: str) -> int:
106
+ return len(str_prompt) // 4
107
+
108
+ def clip_prompt(self, prompt: Any, limit: int) -> str:
109
+ str_prompt = self._prompt_to_str(prompt)
110
+ if not self.use_tiktoken:
111
+ return self._fallback_clip_prompt(str_prompt, limit)
112
+ try:
113
+ import tiktoken
114
+
115
+ enc = tiktoken.get_encoding(self.tiktoken_encoding_name)
116
+ tokens = enc.encode(str_prompt)
117
+ if len(tokens) <= limit:
118
+ return str_prompt
119
+ truncated = tokens[: limit - 3]
120
+ clipped_text = enc.decode(truncated)
121
+ return clipped_text + "..."
122
+ except Exception:
123
+ return self._fallback_clip_prompt(str_prompt, limit)
124
+
125
+ def _fallback_clip_prompt(self, str_prompt: str, limit: int) -> str:
126
+ char_limit = limit * 4 if limit * 4 <= 10 else limit * 4 - 10
127
+ return str_prompt[:char_limit] + "..."
128
+
129
+ async def throttle(
130
+ self,
131
+ prompt: Any,
132
+ throttle_notif_callback: Callable[[str], Any] | None = None,
133
+ ):
134
+ now = time.time()
135
+ str_prompt = self._prompt_to_str(prompt)
136
+ tokens = self.count_token(str_prompt)
137
+ # Clean up old entries
138
+ while self.request_times and now - self.request_times[0] > 60:
139
+ self.request_times.popleft()
140
+ while self.token_times and now - self.token_times[0][0] > 60:
141
+ self.token_times.popleft()
142
+ # Check per-request token limit
143
+ if tokens > self.max_tokens_per_request:
144
+ raise ValueError(
145
+ (
146
+ "Request exceeds max_tokens_per_request "
147
+ f"({tokens} > {self.max_tokens_per_request})."
148
+ )
149
+ )
150
+ if tokens > self.max_tokens_per_minute:
151
+ raise ValueError(
152
+ (
153
+ "Request exceeds max_tokens_per_minute "
154
+ f"({tokens} > {self.max_tokens_per_minute})."
155
+ )
156
+ )
157
+ # Wait if over per-minute request or token limit
158
+ while (
159
+ len(self.request_times) >= self.max_requests_per_minute
160
+ or sum(t for _, t in self.token_times) + tokens > self.max_tokens_per_minute
161
+ ):
162
+ if throttle_notif_callback is not None:
163
+ if len(self.request_times) >= self.max_requests_per_minute:
164
+ rpm = len(self.request_times)
165
+ throttle_notif_callback(
166
+ f"Max request per minute exceeded: {rpm} of {self.max_requests_per_minute}"
167
+ )
168
+ else:
169
+ tpm = sum(t for _, t in self.token_times) + tokens
170
+ throttle_notif_callback(
171
+ f"Max token per minute exceeded: {tpm} of {self.max_tokens_per_minute}"
172
+ )
173
+ await asyncio.sleep(self.throttle_sleep)
174
+ now = time.time()
175
+ while self.request_times and now - self.request_times[0] > 60:
176
+ self.request_times.popleft()
177
+ while self.token_times and now - self.token_times[0][0] > 60:
178
+ self.token_times.popleft()
179
+ # Record this request
180
+ self.request_times.append(now)
181
+ self.token_times.append((now, tokens))
182
+
183
+ def _prompt_to_str(self, prompt: Any) -> str:
184
+ try:
185
+ return json.dumps(prompt)
186
+ except Exception:
187
+ return f"{prompt}"
188
+
189
+
190
+ llm_rate_limitter = LLMRateLimitter()
@@ -1,14 +1,15 @@
1
- from typing import Callable
1
+ from typing import TYPE_CHECKING, Callable
2
2
 
3
- from zrb.config import CFG
4
- from zrb.runner.web_schema.user import User
3
+ from zrb.config.config import CFG
5
4
  from zrb.task.any_task import AnyTask
6
5
 
6
+ if TYPE_CHECKING:
7
+ from zrb.runner.web_schema.user import User
8
+
7
9
 
8
10
  class WebAuthConfig:
9
11
  def __init__(
10
12
  self,
11
- port: int | None = None,
12
13
  secret_key: str | None = None,
13
14
  access_token_expire_minutes: int | None = None,
14
15
  refresh_token_expire_minutes: int | None = None,
@@ -19,9 +20,8 @@ class WebAuthConfig:
19
20
  super_admin_password: str | None = None,
20
21
  guest_username: str | None = None,
21
22
  guest_accessible_tasks: list[AnyTask | str] = [],
22
- find_user_by_username: Callable[[str], User | None] | None = None,
23
+ find_user_by_username: Callable[[str], "User | None"] | None = None,
23
24
  ):
24
- self._port = port
25
25
  self._secret_key = secret_key
26
26
  self._access_token_expire_minutes = access_token_expire_minutes
27
27
  self._refresh_token_expire_minutes = refresh_token_expire_minutes
@@ -31,16 +31,10 @@ class WebAuthConfig:
31
31
  self._super_admin_username = super_admin_username
32
32
  self._super_admin_password = super_admin_password
33
33
  self._guest_username = guest_username
34
- self._user_list = []
34
+ self._user_list: list["User"] = []
35
35
  self._guest_accessible_tasks = guest_accessible_tasks
36
36
  self._find_user_by_username = find_user_by_username
37
37
 
38
- @property
39
- def port(self) -> int:
40
- if self._port is not None:
41
- return self._port
42
- return CFG.WEB_HTTP_PORT
43
-
44
38
  @property
45
39
  def secret_key(self) -> str:
46
40
  if self._secret_key is not None:
@@ -100,7 +94,9 @@ class WebAuthConfig:
100
94
  return self._guest_accessible_tasks
101
95
 
102
96
  @property
103
- def default_user(self) -> User:
97
+ def default_user(self) -> "User":
98
+ from zrb.runner.web_schema.user import User
99
+
104
100
  if self.enable_auth:
105
101
  return User(
106
102
  username=self.guest_username,
@@ -116,7 +112,9 @@ class WebAuthConfig:
116
112
  )
117
113
 
118
114
  @property
119
- def super_admin(self) -> User:
115
+ def super_admin(self) -> "User":
116
+ from zrb.runner.web_schema.user import User
117
+
120
118
  return User(
121
119
  username=self.super_admin_username,
122
120
  password=self.super_admin_password,
@@ -124,14 +122,11 @@ class WebAuthConfig:
124
122
  )
125
123
 
126
124
  @property
127
- def user_list(self) -> list[User]:
125
+ def user_list(self) -> list["User"]:
128
126
  if not self.enable_auth:
129
127
  return [self.default_user]
130
128
  return self._user_list + [self.super_admin, self.default_user]
131
129
 
132
- def set_port(self, port: int):
133
- self._port = port
134
-
135
130
  def set_secret_key(self, secret_key: str):
136
131
  self._secret_key = secret_key
137
132
 
@@ -163,11 +158,11 @@ class WebAuthConfig:
163
158
  self._guest_accessible_tasks = tasks
164
159
 
165
160
  def set_find_user_by_username(
166
- self, find_user_by_username: Callable[[str], User | None]
161
+ self, find_user_by_username: Callable[[str], "User | None"]
167
162
  ):
168
163
  self._find_user_by_username = find_user_by_username
169
164
 
170
- def append_user(self, user: User):
165
+ def append_user(self, user: "User"):
171
166
  duplicates = [
172
167
  existing_user
173
168
  for existing_user in self.user_list
@@ -177,7 +172,7 @@ class WebAuthConfig:
177
172
  raise ValueError(f"User already exists {user.username}")
178
173
  self._user_list.append(user)
179
174
 
180
- def find_user_by_username(self, username: str) -> User | None:
175
+ def find_user_by_username(self, username: str) -> "User | None":
181
176
  user = None
182
177
  if self._find_user_by_username is not None:
183
178
  user = self._find_user_by_username(username)
@@ -19,26 +19,42 @@ class AnySharedContext(ABC):
19
19
  """
20
20
 
21
21
  @property
22
+ @abstractmethod
23
+ def is_web_mode(self) -> bool:
24
+ pass
25
+
26
+ @property
27
+ @abstractmethod
28
+ def is_tty(self) -> bool:
29
+ pass
30
+
31
+ @property
32
+ @abstractmethod
22
33
  def input(self) -> DotDict:
23
34
  pass
24
35
 
25
36
  @property
37
+ @abstractmethod
26
38
  def env(self) -> DotDict:
27
39
  pass
28
40
 
29
41
  @property
42
+ @abstractmethod
30
43
  def args(self) -> list[Any]:
31
44
  pass
32
45
 
33
46
  @property
34
- def xcom(self) -> DotDict[str, Xcom]:
47
+ @abstractmethod
48
+ def xcom(self) -> DotDict:
35
49
  pass
36
50
 
37
51
  @property
52
+ @abstractmethod
38
53
  def shared_log(self) -> list[str]:
39
54
  pass
40
55
 
41
56
  @property
57
+ @abstractmethod
42
58
  def session(self) -> any_session.AnySession | None:
43
59
  pass
44
60
 
zrb/context/context.py CHANGED
@@ -33,6 +33,14 @@ class Context(AnyContext):
33
33
  class_name = self.__class__.__name__
34
34
  return f"<{class_name} shared_ctx={self._shared_ctx}>"
35
35
 
36
+ @property
37
+ def is_web_mode(self) -> bool:
38
+ return self._shared_ctx.is_web_mode
39
+
40
+ @property
41
+ def is_tty(self) -> bool:
42
+ return self._shared_ctx.is_tty
43
+
36
44
  @property
37
45
  def input(self) -> DotDict:
38
46
  return self._shared_ctx.input
@@ -55,7 +63,7 @@ class Context(AnyContext):
55
63
 
56
64
  @property
57
65
  def session(self) -> AnySession | None:
58
- return self._shared_ctx._session
66
+ return self._shared_ctx.session
59
67
 
60
68
  def update_task_env(self, task_env: dict[str, str]):
61
69
  self._env.update(task_env)
@@ -111,7 +119,13 @@ class Context(AnyContext):
111
119
  return
112
120
  color = self._color
113
121
  icon = self._icon
114
- max_name_length = max(len(name) + len(icon) for name in self.session.task_names)
122
+ # Handle case where session is None (e.g., in tests)
123
+ if self.session is None:
124
+ max_name_length = len(self._task_name) + len(icon)
125
+ else:
126
+ max_name_length = max(
127
+ len(name) + len(icon) for name in self.session.task_names
128
+ )
115
129
  styled_task_name = f"{icon} {self._task_name}"
116
130
  padded_styled_task_name = styled_task_name.rjust(max_name_length + 1)
117
131
  if self._attempt == 0:
@@ -1,7 +1,8 @@
1
1
  import datetime
2
+ import sys
2
3
  from typing import Any
3
4
 
4
- from zrb.config import CFG
5
+ from zrb.config.config import CFG
5
6
  from zrb.context.any_shared_context import AnySharedContext
6
7
  from zrb.dot_dict.dot_dict import DotDict
7
8
  from zrb.session.any_session import AnySession
@@ -26,6 +27,7 @@ class SharedContext(AnySharedContext):
26
27
  env: dict[str, str] = {},
27
28
  xcom: dict[str, Xcom] = {},
28
29
  logging_level: int | None = None,
30
+ is_web_mode: bool = False,
29
31
  ):
30
32
  self.__logging_level = logging_level
31
33
  self._input = DotDict(input)
@@ -34,14 +36,22 @@ class SharedContext(AnySharedContext):
34
36
  self._xcom = DotDict(xcom)
35
37
  self._session: AnySession | None = None
36
38
  self._log = []
39
+ self._is_web_mode = is_web_mode
37
40
 
38
41
  def __repr__(self):
39
42
  class_name = self.__class__.__name__
40
- input = self._input
41
- args = self._args
42
- env = self._env
43
- xcom = self._xcom
44
- return f"<{class_name} input={input} args={args} xcom={xcom} env={env}>"
43
+ return f"<{class_name}>"
44
+
45
+ @property
46
+ def is_web_mode(self) -> bool:
47
+ return self._is_web_mode
48
+
49
+ @property
50
+ def is_tty(self) -> bool:
51
+ try:
52
+ return sys.stdin.isatty()
53
+ except Exception:
54
+ return False
45
55
 
46
56
  @property
47
57
  def input(self) -> DotDict:
@@ -56,7 +66,7 @@ class SharedContext(AnySharedContext):
56
66
  return self._args
57
67
 
58
68
  @property
59
- def xcom(self) -> DotDict[str, Xcom]:
69
+ def xcom(self) -> DotDict:
60
70
  return self._xcom
61
71
 
62
72
  @property
@@ -71,7 +81,7 @@ class SharedContext(AnySharedContext):
71
81
  self._log.append(message)
72
82
  session = self.session
73
83
  if session is not None:
74
- session_parent: AnySession = session.parent
84
+ session_parent: AnySession | None = session.parent
75
85
  if session_parent is not None:
76
86
  session_parent.shared_ctx.append_to_shared_log(message)
77
87
 
zrb/group/any_group.py CHANGED
@@ -1,5 +1,4 @@
1
1
  from abc import ABC, abstractmethod
2
- from typing import Optional, Union
3
2
 
4
3
  from zrb.task.any_task import AnyTask
5
4
 
@@ -31,16 +30,24 @@ class AnyGroup(ABC):
31
30
 
32
31
  @property
33
32
  @abstractmethod
34
- def subgroups(self) -> dict[str, "AnyGroup"]:
33
+ def subgroups(self) -> "dict[str, AnyGroup]":
35
34
  """Group subgroups"""
36
35
  pass
37
36
 
38
37
  @abstractmethod
39
- def add_group(self, group: Union["AnyGroup", str]) -> "AnyGroup":
38
+ def add_group(self, group: "AnyGroup", alias: str | None = None) -> "AnyGroup":
40
39
  pass
41
40
 
42
41
  @abstractmethod
43
- def add_task(self, task: AnyTask, alias: str | None = None) -> AnyTask:
42
+ def add_task(self, task: "AnyTask", alias: str | None = None) -> "AnyTask":
43
+ pass
44
+
45
+ @abstractmethod
46
+ def remove_group(self, group: "AnyGroup | str"):
47
+ pass
48
+
49
+ @abstractmethod
50
+ def remove_task(self, task: "AnyTask | str"):
44
51
  pass
45
52
 
46
53
  @abstractmethod
@@ -48,5 +55,5 @@ class AnyGroup(ABC):
48
55
  pass
49
56
 
50
57
  @abstractmethod
51
- def get_group_by_alias(self, name: str) -> Optional["AnyGroup"]:
58
+ def get_group_by_alias(self, alias: str) -> "AnyGroup | None":
52
59
  pass
zrb/group/group.py CHANGED
@@ -33,15 +33,15 @@ class Group(AnyGroup):
33
33
  def subgroups(self) -> dict[str, AnyGroup]:
34
34
  names = list(self._groups.keys())
35
35
  names.sort()
36
- return {name: self._groups.get(name) for name in names}
36
+ return {name: self._groups[name] for name in names}
37
37
 
38
38
  @property
39
39
  def subtasks(self) -> dict[str, AnyTask]:
40
40
  alias = list(self._tasks.keys())
41
41
  alias.sort()
42
- return {name: self._tasks.get(name) for name in alias}
42
+ return {name: self._tasks[name] for name in alias}
43
43
 
44
- def add_group(self, group: AnyGroup | str, alias: str | None = None) -> AnyGroup:
44
+ def add_group(self, group: AnyGroup, alias: str | None = None) -> AnyGroup:
45
45
  real_group = Group(group) if isinstance(group, str) else group
46
46
  alias = alias if alias is not None else real_group.name
47
47
  self._groups[alias] = real_group
@@ -52,6 +52,70 @@ class Group(AnyGroup):
52
52
  self._tasks[alias] = task
53
53
  return task
54
54
 
55
+ def remove_group(self, group: "AnyGroup | str"):
56
+ original_groups_len = len(self._groups)
57
+ if isinstance(group, AnyGroup):
58
+ new_groups = {
59
+ alias: existing_group
60
+ for alias, existing_group in self._groups.items()
61
+ if group != existing_group
62
+ }
63
+ if len(new_groups) == original_groups_len:
64
+ raise ValueError(f"Cannot remove group {group} from {self}")
65
+ self._groups = new_groups
66
+ return
67
+ # group is string, try to remove by alias
68
+ new_groups = {
69
+ alias: existing_group
70
+ for alias, existing_group in self._groups.items()
71
+ if alias != group
72
+ }
73
+ if len(new_groups) < original_groups_len:
74
+ self._groups = new_groups
75
+ return
76
+ # if alias removal didn't work, try to remove by name
77
+ new_groups = {
78
+ alias: existing_group
79
+ for alias, existing_group in self._groups.items()
80
+ if existing_group.name != group
81
+ }
82
+ if len(new_groups) < original_groups_len:
83
+ self._groups = new_groups
84
+ return
85
+ raise ValueError(f"Cannot remove group {group} from {self}")
86
+
87
+ def remove_task(self, task: "AnyTask | str"):
88
+ original_tasks_len = len(self._tasks)
89
+ if isinstance(task, AnyTask):
90
+ new_tasks = {
91
+ alias: existing_task
92
+ for alias, existing_task in self._tasks.items()
93
+ if task != existing_task
94
+ }
95
+ if len(new_tasks) == original_tasks_len:
96
+ raise ValueError(f"Cannot remove task {task} from {self}")
97
+ self._tasks = new_tasks
98
+ return
99
+ # task is string, try to remove by alias
100
+ new_tasks = {
101
+ alias: existing_task
102
+ for alias, existing_task in self._tasks.items()
103
+ if alias != task
104
+ }
105
+ if len(new_tasks) < original_tasks_len:
106
+ self._tasks = new_tasks
107
+ return
108
+ # if alias removal didn't work, try to remove by name
109
+ new_tasks = {
110
+ alias: existing_task
111
+ for alias, existing_task in self._tasks.items()
112
+ if existing_task.name != task
113
+ }
114
+ if len(new_tasks) < original_tasks_len:
115
+ self._tasks = new_tasks
116
+ return
117
+ raise ValueError(f"Cannot remove task {task} from {self}")
118
+
55
119
  def get_task_by_alias(self, alias: str) -> AnyTask | None:
56
120
  return self._tasks.get(alias)
57
121
 
zrb/input/any_input.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from abc import ABC, abstractmethod
2
+ from typing import Any
2
3
 
3
4
  from zrb.context.any_shared_context import AnySharedContext
4
5
 
@@ -35,7 +36,10 @@ class AnyInput(ABC):
35
36
 
36
37
  @abstractmethod
37
38
  def update_shared_context(
38
- self, shared_ctx: AnySharedContext, str_value: str | None = None
39
+ self,
40
+ shared_ctx: AnySharedContext,
41
+ str_value: str | None = None,
42
+ value: Any = None,
39
43
  ):
40
44
  pass
41
45
 
zrb/input/base_input.py CHANGED
@@ -58,11 +58,15 @@ class BaseInput(AnyInput):
58
58
  return f'<input name="{name}" placeholder="{description}" value="{default}" />'
59
59
 
60
60
  def update_shared_context(
61
- self, shared_ctx: AnySharedContext, str_value: str | None = None
61
+ self,
62
+ shared_ctx: AnySharedContext,
63
+ str_value: str | None = None,
64
+ value: Any = None,
62
65
  ):
63
- if str_value is None:
64
- str_value = self.get_default_str(shared_ctx)
65
- value = self._parse_str_value(str_value)
66
+ if value is None:
67
+ if str_value is None:
68
+ str_value = self.get_default_str(shared_ctx)
69
+ value = self._parse_str_value(str_value)
66
70
  if self.name in shared_ctx.input:
67
71
  raise ValueError(f"Input already defined in the context: {self.name}")
68
72
  shared_ctx.input[self.name] = value
@@ -91,12 +95,20 @@ class BaseInput(AnyInput):
91
95
  default_str = self.get_default_str(shared_ctx)
92
96
  if default_str != "":
93
97
  prompt_message = f"{prompt_message} [{default_str}]"
94
- print(f"{prompt_message}: ", end="")
95
- value = input()
98
+ value = self._read_line(shared_ctx, prompt_message)
96
99
  if value.strip() == "":
97
100
  value = default_str
98
101
  return value
99
102
 
103
+ def _read_line(self, shared_ctx: AnySharedContext, prompt_message: str) -> str:
104
+ if not shared_ctx.is_tty:
105
+ print(f"{prompt_message}: ", end="")
106
+ return input()
107
+ from prompt_toolkit import PromptSession
108
+
109
+ reader = PromptSession()
110
+ return reader.prompt(f"{prompt_message}: ")
111
+
100
112
  def get_default_str(self, shared_ctx: AnySharedContext) -> str:
101
113
  """Get default value as str"""
102
114
  default_value = get_attr(
zrb/input/option_input.py CHANGED
@@ -47,9 +47,21 @@ class OptionInput(BaseInput):
47
47
  option_str = ", ".join(options)
48
48
  if default_value != "":
49
49
  prompt_message = f"{prompt_message} ({option_str}) [{default_value}]"
50
- value = input(f"{prompt_message}: ")
50
+ value = self._get_value_from_user_input(shared_ctx, prompt_message, options)
51
51
  if value.strip() != "" and value.strip() not in options:
52
52
  value = self._prompt_cli_str(shared_ctx)
53
53
  if value.strip() == "":
54
54
  value = default_value
55
55
  return value
56
+
57
+ def _get_value_from_user_input(
58
+ self, shared_ctx: AnySharedContext, prompt_message: str, options: list[str]
59
+ ) -> str:
60
+ from prompt_toolkit import PromptSession
61
+ from prompt_toolkit.completion import WordCompleter
62
+
63
+ if shared_ctx.is_tty:
64
+ reader = PromptSession()
65
+ option_completer = WordCompleter(options, ignore_case=True)
66
+ return reader.prompt(f"{prompt_message}: ", completer=option_completer)
67
+ return input(f"{prompt_message}: ")