zrb 1.0.0a10__py3-none-any.whl → 1.0.0a14__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 (79) hide show
  1. zrb/builtin/__init__.py +15 -5
  2. zrb/builtin/project/add/fastapp.py +32 -17
  3. zrb/builtin/project/add/fastapp_template/_zrb/column/create_column_task.py +11 -0
  4. zrb/builtin/project/add/fastapp_template/_zrb/config.py +4 -4
  5. zrb/builtin/project/add/fastapp_template/_zrb/entity/create_entity_task.py +196 -0
  6. zrb/builtin/project/add/fastapp_template/_zrb/entity/module_template/service/my_entity/my_entity_usecase.py +66 -0
  7. zrb/builtin/project/add/fastapp_template/_zrb/entity/module_template/service/my_entity/repository/factory.py +13 -0
  8. zrb/builtin/project/add/fastapp_template/_zrb/entity/module_template/service/my_entity/repository/my_entity_db_repository.py +33 -0
  9. zrb/builtin/project/add/fastapp_template/_zrb/entity/module_template/service/my_entity/repository/my_entity_repository.py +39 -0
  10. zrb/builtin/project/add/fastapp_template/_zrb/entity/schema.template.py +29 -0
  11. zrb/builtin/project/add/fastapp_template/_zrb/group.py +9 -5
  12. zrb/builtin/project/add/fastapp_template/_zrb/helper.py +25 -11
  13. zrb/builtin/project/add/fastapp_template/_zrb/input.py +43 -0
  14. zrb/builtin/project/add/fastapp_template/_zrb/main.py +30 -21
  15. zrb/builtin/project/add/fastapp_template/_zrb/module/create_module_task.py +136 -0
  16. zrb/builtin/project/add/fastapp_template/_zrb/module/module_template/alembic.ini +117 -0
  17. zrb/builtin/project/add/fastapp_template/_zrb/module/module_template/client/api_client.py +6 -0
  18. zrb/builtin/project/add/fastapp_template/_zrb/module/module_template/client/direct_client.py +6 -0
  19. zrb/builtin/project/add/fastapp_template/_zrb/module/module_template/client/factory.py +9 -0
  20. zrb/builtin/project/add/fastapp_template/_zrb/module/module_template/migration/README +1 -0
  21. zrb/builtin/project/add/fastapp_template/_zrb/module/module_template/migration/env.py +108 -0
  22. zrb/builtin/project/add/fastapp_template/_zrb/module/module_template/migration/script.py.mako +26 -0
  23. zrb/builtin/project/add/fastapp_template/_zrb/module/module_template/migration/versions/3093c7336477_add_user_table.py +37 -0
  24. zrb/builtin/project/add/fastapp_template/_zrb/module/module_template/migration_metadata.py +3 -0
  25. zrb/builtin/project/add/fastapp_template/_zrb/module/module_template/route.py +19 -0
  26. zrb/builtin/project/add/fastapp_template/_zrb/module/run_module.template.py +26 -0
  27. zrb/builtin/project/add/fastapp_template/_zrb/venv_task.py +2 -5
  28. zrb/builtin/project/add/fastapp_template/common/app.py +3 -1
  29. zrb/builtin/project/add/fastapp_template/config.py +7 -7
  30. zrb/builtin/project/add/fastapp_template/module/auth/client/any_client.py +27 -0
  31. zrb/builtin/project/add/fastapp_template/module/auth/client/api_client.py +2 -2
  32. zrb/builtin/project/add/fastapp_template/module/auth/client/direct_client.py +2 -2
  33. zrb/builtin/project/add/fastapp_template/module/auth/client/factory.py +1 -1
  34. zrb/builtin/project/add/fastapp_template/module/auth/route.py +1 -1
  35. zrb/builtin/project/add/fastapp_template/module/auth/service/user/repository/factory.py +2 -2
  36. zrb/builtin/project/add/fastapp_template/module/auth/service/user/repository/{db_repository.py → user_db_repository.py} +2 -2
  37. zrb/builtin/project/add/fastapp_template/module/auth/service/user/{usecase.py → user_usecase.py} +15 -8
  38. zrb/builtin/project/add/fastapp_template/requirements.txt +5 -6
  39. zrb/builtin/project/add/fastapp_template/schema/permission.py +31 -0
  40. zrb/builtin/project/add/fastapp_template/template.env +2 -2
  41. zrb/builtin/project/create/create.py +2 -2
  42. zrb/builtin/project/create/project-template/zrb_init.py +0 -4
  43. zrb/builtin/setup/{dev → asdf}/asdf.py +6 -6
  44. zrb/builtin/setup/{system/latex → latex}/ubuntu.py +1 -1
  45. zrb/builtin/setup/{dev → tmux}/tmux.py +1 -1
  46. zrb/builtin/setup/tmux/tmux_config.sh +12 -0
  47. zrb/builtin/todo.py +101 -15
  48. zrb/config.py +2 -2
  49. zrb/content_transformer/content_transformer.py +14 -2
  50. zrb/context/context.py +13 -10
  51. zrb/input/base_input.py +3 -2
  52. zrb/input/bool_input.py +1 -1
  53. zrb/input/float_input.py +1 -1
  54. zrb/input/int_input.py +1 -1
  55. zrb/input/option_input.py +1 -1
  56. zrb/input/password_input.py +1 -1
  57. zrb/input/text_input.py +1 -1
  58. zrb/runner/cli.py +16 -5
  59. zrb/runner/web_app.py +30 -18
  60. zrb/runner/web_controller/task_ui/controller.py +8 -6
  61. zrb/session/session.py +4 -1
  62. zrb/task/scaffolder.py +7 -9
  63. zrb/util/cli/style.py +7 -0
  64. zrb/util/codemod/add_code_to_module.py +12 -0
  65. zrb/util/load.py +9 -7
  66. zrb/util/string/conversion.py +52 -0
  67. zrb/util/todo.py +152 -34
  68. {zrb-1.0.0a10.dist-info → zrb-1.0.0a14.dist-info}/METADATA +1 -2
  69. {zrb-1.0.0a10.dist-info → zrb-1.0.0a14.dist-info}/RECORD +79 -55
  70. {zrb-1.0.0a10.dist-info → zrb-1.0.0a14.dist-info}/WHEEL +1 -1
  71. /zrb/builtin/project/add/fastapp_template/{module/auth/client/base_client.py → _zrb/module/module_template/client/any_client.py} +0 -0
  72. /zrb/builtin/{setup/dev/tmux_config.sh → project/add/fastapp_template/_zrb/module/module_template/service/__init__.py} +0 -0
  73. /zrb/builtin/project/add/fastapp_template/common/{db_repository.py → base_db_repository.py} +0 -0
  74. /zrb/builtin/project/add/fastapp_template/common/{usecase.py → base_usecase.py} +0 -0
  75. /zrb/builtin/project/add/fastapp_template/module/auth/service/user/repository/{repository.py → user_repository.py} +0 -0
  76. /zrb/builtin/setup/{dev → asdf}/asdf_helper.py +0 -0
  77. /zrb/builtin/setup/{dev → tmux}/tmux_helper.py +0 -0
  78. /zrb/builtin/setup/{system/ubuntu.py → ubuntu.py} +0 -0
  79. {zrb-1.0.0a10.dist-info → zrb-1.0.0a14.dist-info}/entry_points.txt +0 -0
zrb/input/base_input.py CHANGED
@@ -14,7 +14,7 @@ class BaseInput(AnyInput):
14
14
  prompt: str | None = None,
15
15
  default_str: StrAttr = "",
16
16
  auto_render: bool = True,
17
- allow_empty: bool = True,
17
+ allow_empty: bool = False,
18
18
  ):
19
19
  self._name = name
20
20
  self._description = description
@@ -66,7 +66,8 @@ class BaseInput(AnyInput):
66
66
  default_value = self._get_default_str(shared_ctx)
67
67
  if default_value != "":
68
68
  prompt_message = f"{prompt_message} [{default_value}]"
69
- value = input(f"{prompt_message}: ")
69
+ print(f"{prompt_message}: ", end="")
70
+ value = input()
70
71
  if value.strip() == "":
71
72
  value = default_value
72
73
  return value
zrb/input/bool_input.py CHANGED
@@ -12,7 +12,7 @@ class BoolInput(BaseInput):
12
12
  prompt: str | None = None,
13
13
  default_str: StrAttr = "False",
14
14
  auto_render: bool = True,
15
- allow_empty: bool = True,
15
+ allow_empty: bool = False,
16
16
  ):
17
17
  super().__init__(
18
18
  name=name,
zrb/input/float_input.py CHANGED
@@ -11,7 +11,7 @@ class FloatInput(BaseInput):
11
11
  prompt: str | None = None,
12
12
  default_str: StrAttr = "0.0",
13
13
  auto_render: bool = True,
14
- allow_empty: bool = True,
14
+ allow_empty: bool = False,
15
15
  ):
16
16
  super().__init__(
17
17
  name=name,
zrb/input/int_input.py CHANGED
@@ -11,7 +11,7 @@ class IntInput(BaseInput):
11
11
  prompt: str | None = None,
12
12
  default_str: StrAttr = "0",
13
13
  auto_render: bool = True,
14
- allow_empty: bool = True,
14
+ allow_empty: bool = False,
15
15
  ):
16
16
  super().__init__(
17
17
  name=name,
zrb/input/option_input.py CHANGED
@@ -13,7 +13,7 @@ class OptionInput(BaseInput):
13
13
  options: StrListAttr = [],
14
14
  default_str: StrAttr = "",
15
15
  auto_render: bool = True,
16
- allow_empty: bool = True,
16
+ allow_empty: bool = False,
17
17
  ):
18
18
  super().__init__(
19
19
  name=name,
@@ -13,7 +13,7 @@ class PasswordInput(BaseInput):
13
13
  prompt: str | None = None,
14
14
  default_str: str | Callable[[AnySharedContext], str] = "",
15
15
  auto_render: bool = True,
16
- allow_empty: bool = True,
16
+ allow_empty: bool = False,
17
17
  ):
18
18
  super().__init__(
19
19
  name=name,
zrb/input/text_input.py CHANGED
@@ -16,7 +16,7 @@ class TextInput(BaseInput):
16
16
  prompt: str | None = None,
17
17
  default_str: str | Callable[[AnySharedContext], str] = "",
18
18
  auto_render: bool = True,
19
- allow_empty: bool = True,
19
+ allow_empty: bool = False,
20
20
  editor: str = DEFAULT_EDITOR,
21
21
  extension: str = ".txt",
22
22
  comment_start: str | None = None,
zrb/runner/cli.py CHANGED
@@ -82,13 +82,24 @@ class Cli(Group):
82
82
  shared_ctx = SharedContext(args=args)
83
83
  for task_input in task.inputs:
84
84
  if task_input.name in str_kwargs:
85
- continue
86
- if arg_index < len(args):
85
+ # Update shared context for next input default value
86
+ task_input.update_shared_context(
87
+ shared_ctx, str_kwargs[task_input.name]
88
+ )
89
+ elif arg_index < len(args):
87
90
  run_kwargs[task_input.name] = args[arg_index]
91
+ # Update shared context for next input default value
92
+ task_input.update_shared_context(
93
+ shared_ctx, run_kwargs[task_input.name]
94
+ )
88
95
  arg_index += 1
89
- continue
90
- str_value = task_input.prompt_cli_str(shared_ctx)
91
- run_kwargs[task_input.name] = str_value
96
+ else:
97
+ str_value = task_input.prompt_cli_str(shared_ctx)
98
+ run_kwargs[task_input.name] = str_value
99
+ # Update shared context for next input default value
100
+ task_input.update_shared_context(
101
+ shared_ctx, run_kwargs[task_input.name]
102
+ )
92
103
  return run_kwargs
93
104
 
94
105
  def _show_task_info(self, task: AnyTask):
zrb/runner/web_app.py CHANGED
@@ -2,7 +2,7 @@ import asyncio
2
2
  import os
3
3
  import sys
4
4
  from datetime import datetime, timedelta
5
- from typing import Any, Dict, List
5
+ from typing import Any
6
6
 
7
7
  from zrb.config import BANNER, WEB_HTTP_PORT
8
8
  from zrb.context.shared_context import SharedContext
@@ -23,7 +23,7 @@ from zrb.util.group import extract_node_from_args, get_node_path
23
23
  def create_app(root_group: AnyGroup, port: int = WEB_HTTP_PORT):
24
24
  from contextlib import asynccontextmanager
25
25
 
26
- from fastapi import FastAPI, HTTPException, Request
26
+ from fastapi import FastAPI, HTTPException, Query, Request
27
27
  from fastapi.responses import FileResponse, HTMLResponse
28
28
  from fastapi.staticfiles import StaticFiles
29
29
 
@@ -64,7 +64,7 @@ def create_app(root_group: AnyGroup, port: int = WEB_HTTP_PORT):
64
64
  # Avoid capturing '/ui' itself
65
65
  if not path:
66
66
  raise HTTPException(status_code=404, detail="Not Found")
67
- args = path.split("/")
67
+ args = path.strip("/").split("/")
68
68
  node, node_path, residual_args = extract_node_from_args(root_group, args)
69
69
  url = f"/ui/{'/'.join(node_path)}/"
70
70
  if isinstance(node, AnyTask):
@@ -82,7 +82,7 @@ def create_app(root_group: AnyGroup, port: int = WEB_HTTP_PORT):
82
82
  """
83
83
  Creating new session
84
84
  """
85
- args = path.split("/")
85
+ args = path.strip("/").split("/")
86
86
  node, _, residual_args = extract_node_from_args(root_group, args)
87
87
  if isinstance(node, AnyTask):
88
88
  session_name = residual_args[0] if residual_args else None
@@ -97,33 +97,45 @@ def create_app(root_group: AnyGroup, port: int = WEB_HTTP_PORT):
97
97
  raise HTTPException(status_code=404, detail="Not Found")
98
98
 
99
99
  @app.get("/api/{path:path}", response_model=SessionStateLog | SessionStateLogList)
100
- async def get_session(path: str, query_params: Dict[str, Any] = {}):
100
+ async def get_session(
101
+ path: str,
102
+ min_start_query: str = Query(default=None, alias="from"),
103
+ max_start_query: str = Query(default=None, alias="to"),
104
+ page: int = Query(default=0, alias="page"),
105
+ limit: int = Query(default=10, alias="limit"),
106
+ ):
101
107
  """
102
108
  Getting existing session or sessions
103
109
  """
104
- args = path.split("/")
110
+ args = path.strip("/").split("/")
105
111
  node, _, residual_args = extract_node_from_args(root_group, args)
106
112
  if isinstance(node, AnyTask) and residual_args:
107
113
  if residual_args[0] == "list":
108
114
  task_path = get_node_path(root_group, node)
109
- return list_sessions(task_path, query_params)
115
+ max_start_time = (
116
+ datetime.now()
117
+ if max_start_query is None
118
+ else datetime.strptime(max_start_query, "%Y-%m-%d %H:%M:%S")
119
+ )
120
+ min_start_time = (
121
+ max_start_time - timedelta(hours=1)
122
+ if min_start_query is None
123
+ else datetime.strptime(min_start_query, "%Y-%m-%d %H:%M:%S")
124
+ )
125
+ return list_sessions(
126
+ task_path, min_start_time, max_start_time, page, limit
127
+ )
110
128
  else:
111
129
  return read_session(residual_args[0])
112
130
  raise HTTPException(status_code=404, detail="Not Found")
113
131
 
114
132
  def list_sessions(
115
- task_path: List[str], query_params: Dict[str, Any]
133
+ task_path: list[str],
134
+ min_start_time: datetime,
135
+ max_start_time: datetime,
136
+ page: int,
137
+ limit: int,
116
138
  ) -> SessionStateLogList:
117
- max_start_time = datetime.now()
118
- if "to" in query_params:
119
- max_start_time = datetime.strptime(query_params["to"], "%Y-%m-%d %H:%M:%S")
120
- min_start_time = max_start_time - timedelta(hours=1)
121
- if "from" in query_params:
122
- min_start_time = datetime.strptime(
123
- query_params["from"], "%Y-%m-%d %H:%M:%S"
124
- )
125
- page = int(query_params.get("page", 0))
126
- limit = int(query_params.get("limit", 10))
127
139
  try:
128
140
  return default_session_state_logger.list(
129
141
  task_path,
@@ -1,5 +1,6 @@
1
1
  import os
2
2
 
3
+ from zrb.context.shared_context import SharedContext
3
4
  from zrb.group.any_group import AnyGroup
4
5
  from zrb.session.any_session import AnySession
5
6
  from zrb.task.any_task import AnyTask
@@ -45,12 +46,13 @@ def handle_task_ui(
45
46
  ui_url_parts = list(api_url_parts)
46
47
  ui_url_parts[1] = "ui"
47
48
  ui_url = "/".join(ui_url_parts)
48
- task_inputs = "\n".join(
49
- [
49
+ # Assemble task inputs
50
+ input_html_list = []
51
+ for task_input in task.inputs:
52
+ task_input.update_shared_context(ctx)
53
+ input_html_list.append(
50
54
  fstring_format(_TASK_INPUT_TEMPLATE, {"task_input": task_input, "ctx": ctx})
51
- for task_input in task.inputs
52
- ]
53
- )
55
+ )
54
56
  session_name = args[0] if len(args) > 0 else ""
55
57
  return HTMLResponse(
56
58
  fstring_format(
@@ -62,7 +64,7 @@ def handle_task_ui(
62
64
  "root_description": root_group.description,
63
65
  "url": url,
64
66
  "parent_url": parent_url,
65
- "task_inputs": task_inputs,
67
+ "task_inputs": "\n".join(input_html_list),
66
68
  "api_url": api_url,
67
69
  "ui_url": ui_url,
68
70
  "main_script": _MAIN_SCRIPT,
zrb/session/session.py CHANGED
@@ -28,6 +28,7 @@ from zrb.util.cli.style import (
28
28
  ICONS,
29
29
  MAGENTA,
30
30
  YELLOW,
31
+ remove_style,
31
32
  )
32
33
  from zrb.util.group import get_node_path
33
34
  from zrb.util.string.name import get_random_name
@@ -165,7 +166,9 @@ class Session(AnySession):
165
166
  main_task_name=self._main_task.name,
166
167
  path=self.task_path,
167
168
  final_result=(
168
- f"{self.final_result}" if self.final_result is not None else ""
169
+ remove_style(f"{self.final_result}")
170
+ if self.final_result is not None
171
+ else ""
169
172
  ),
170
173
  finished=self.is_terminated,
171
174
  log=self.shared_ctx.shared_log,
zrb/task/scaffolder.py CHANGED
@@ -92,8 +92,8 @@ class Scaffolder(BaseTask):
92
92
  destination_path = self._get_destination_path(ctx)
93
93
  self._copy_path(ctx, source_path, destination_path)
94
94
  transformers = self._get_content_transformers()
95
- file_paths = self._get_all_file_paths(destination_path)
96
- for file_path in file_paths:
95
+ file_path_list = self._get_all_file_paths(destination_path)
96
+ for file_path in file_path_list:
97
97
  for transformer in transformers:
98
98
  if transformer.match(ctx, file_path):
99
99
  try:
@@ -118,14 +118,10 @@ class Scaffolder(BaseTask):
118
118
  dest_dir, self._transform_path(ctx, file_name)
119
119
  )
120
120
  shutil.copy2(src_file, dest_file)
121
- ctx.log_info(f"Copied and renamed {src_file} to {dest_file}")
121
+ ctx.log_info(f"Copied {src_file} to {dest_file}")
122
122
  else:
123
- dest_file = os.path.join(
124
- destination_path,
125
- self._transform_path(ctx, os.path.basename(source_path)),
126
- )
127
- shutil.copy2(source_path, dest_file)
128
- ctx.log_info(f"Copied and renamed {source_path} to {dest_file}")
123
+ shutil.copy2(source_path, destination_path)
124
+ ctx.log_info(f"Copied {source_path} to {destination_path}")
129
125
 
130
126
  def _transform_path(self, ctx: AnyContext, file_path: str):
131
127
  if callable(self._path_transformer):
@@ -139,6 +135,8 @@ class Scaffolder(BaseTask):
139
135
  """
140
136
  Returns a list of absolute file paths for all files in the given path, recursively.
141
137
  """
138
+ if os.path.isfile(path):
139
+ return [os.path.abspath(path)]
142
140
  file_paths = []
143
141
  for root, _, files in os.walk(path):
144
142
  for file in files:
zrb/util/cli/style.py CHANGED
@@ -1,3 +1,5 @@
1
+ import re
2
+
1
3
  BLACK = 30
2
4
  RED = 31
3
5
  GREEN = 32
@@ -118,6 +120,11 @@ ICONS = [
118
120
  ]
119
121
 
120
122
 
123
+ def remove_style(text):
124
+ ansi_escape = re.compile(r"\x1B[@-_][0-?]*[ -/]*[@-~]")
125
+ return ansi_escape.sub("", text)
126
+
127
+
121
128
  def stylize(
122
129
  text: str,
123
130
  color: int | None = None,
@@ -0,0 +1,12 @@
1
+ def add_code_to_module(source_code: str, new_code: str) -> str:
2
+ lines = source_code.splitlines()
3
+ last_import_index = -1
4
+ for i, line in enumerate(lines):
5
+ stripped_line = line.strip()
6
+ if stripped_line.startswith("import") or stripped_line.startswith("from"):
7
+ last_import_index = i
8
+ elif stripped_line and not stripped_line.startswith("#"):
9
+ break
10
+ if last_import_index != -1:
11
+ lines.insert(last_import_index + 1, new_code)
12
+ return "\n".join(lines)
zrb/util/load.py CHANGED
@@ -3,37 +3,39 @@ import importlib.util
3
3
  import os
4
4
  import re
5
5
  import sys
6
+ from functools import lru_cache
6
7
  from typing import Any
7
8
 
8
9
  pattern = re.compile("[^a-zA-Z0-9]")
9
10
 
10
11
 
11
- def load_zrb_init(dir_path: str | None = None):
12
+ def load_zrb_init(dir_path: str | None = None) -> Any | None:
12
13
  if dir_path is None:
13
14
  dir_path = os.getcwd()
14
15
  script_path = os.path.join(dir_path, "zrb_init.py")
15
16
  if os.path.isfile(script_path):
16
- load_file(script_path)
17
- return
17
+ return load_file(script_path, -1)
18
18
  new_dir_path = os.path.dirname(dir_path)
19
19
  if new_dir_path == dir_path:
20
20
  return
21
- load_zrb_init(new_dir_path)
21
+ return load_zrb_init(new_dir_path)
22
22
 
23
23
 
24
- def load_file(script_path: str, sys_path_index: int = 0):
24
+ @lru_cache
25
+ def load_file(script_path: str, sys_path_index: int = 0) -> Any | None:
25
26
  if not os.path.isfile(script_path):
26
- return
27
+ return None
28
+ module_name = pattern.sub("", script_path)
27
29
  # Append script dir path
28
30
  script_dir_path = os.path.dirname(script_path)
29
31
  if script_dir_path not in sys.path:
30
32
  sys.path.insert(sys_path_index, script_dir_path)
31
33
  # Add script dir path to Python path
32
34
  os.environ["PYTHONPATH"] = _get_new_python_path(script_dir_path)
33
- module_name = pattern.sub("", script_path)
34
35
  spec = importlib.util.spec_from_file_location(module_name, script_path)
35
36
  module = importlib.util.module_from_spec(spec)
36
37
  spec.loader.exec_module(module)
38
+ return module
37
39
 
38
40
 
39
41
  def _get_new_python_path(dir_path: str) -> str:
@@ -63,6 +63,58 @@ def to_human_case(text: str | None) -> str:
63
63
  )
64
64
 
65
65
 
66
+ def pluralize(noun: str) -> str:
67
+ """
68
+ Pluralize a given noun.
69
+
70
+ Args:
71
+ noun (str): The singular noun.
72
+
73
+ Returns:
74
+ str: The plural form of the noun.
75
+ """
76
+ # Irregular plural forms
77
+ irregulars = {
78
+ "foot": "feet",
79
+ "tooth": "teeth",
80
+ "child": "children",
81
+ "person": "people",
82
+ "man": "men",
83
+ "woman": "women",
84
+ "mouse": "mice",
85
+ "goose": "geese",
86
+ "ox": "oxen",
87
+ "cactus": "cacti",
88
+ "focus": "foci",
89
+ "fungus": "fungi",
90
+ "nucleus": "nuclei",
91
+ "syllabus": "syllabi",
92
+ "analysis": "analyses",
93
+ "diagnosis": "diagnoses",
94
+ "thesis": "theses",
95
+ "crisis": "crises",
96
+ "phenomenon": "phenomena",
97
+ "criterion": "criteria",
98
+ }
99
+
100
+ # Handle irregular nouns
101
+ if noun.lower() in irregulars:
102
+ return irregulars[noun.lower()]
103
+ # Handle words ending in 'y' preceded by a consonant
104
+ if noun.endswith("y") and not re.match(r"[aeiou]y$", noun):
105
+ return re.sub(r"y$", "ies", noun)
106
+ # Handle words ending in 's', 'x', 'z', 'ch', or 'sh'
107
+ if re.search(r"(s|x|z|ch|sh)$", noun):
108
+ return noun + "es"
109
+ # Handle words ending in 'f' or 'fe'
110
+ if re.search(r"f$|fe$", noun):
111
+ if noun.endswith("fe"):
112
+ return re.sub(r"fe$", "ves", noun)
113
+ return re.sub(r"f$", "ves", noun)
114
+ # Handle general case for regular plurals
115
+ return noun + "s"
116
+
117
+
66
118
  def _to_space_separated(text: str | None) -> str:
67
119
  text = str(text) if text is not None else ""
68
120
  text = text.replace("-", " ").replace("_", " ")
zrb/util/todo.py CHANGED
@@ -1,11 +1,15 @@
1
1
  import datetime
2
2
  import re
3
+ import shutil
4
+ from typing import Any
3
5
 
4
6
  from pydantic import BaseModel, Field, model_validator
5
7
 
6
8
  from zrb.util.cli.style import (
7
9
  stylize_bold_green,
10
+ stylize_bold_yellow,
8
11
  stylize_cyan,
12
+ stylize_faint,
9
13
  stylize_magenta,
10
14
  stylize_yellow,
11
15
  )
@@ -32,6 +36,16 @@ class TodoTaskModel(BaseModel):
32
36
  )
33
37
  return values
34
38
 
39
+ def get_additional_info_length(self):
40
+ results = []
41
+ for project in self.projects:
42
+ results.append(f"@{project}")
43
+ for context in self.contexts:
44
+ results.append(f"+{context}")
45
+ for key, val in self.keyval.items():
46
+ results.append(f"{key}:{val}")
47
+ return len(", ".join(results))
48
+
35
49
 
36
50
  TODO_TXT_PATTERN = re.compile(
37
51
  r"^(?P<status>x)?\s*" # Optional completion mark ('x')
@@ -80,7 +94,8 @@ def load_todo_list(todo_file_path: str) -> list[TodoTaskModel]:
80
94
  todo_line = todo_line.strip()
81
95
  if todo_line == "":
82
96
  continue
83
- todo_list.append(line_to_todo_task(todo_line))
97
+ todo_task = line_to_todo_task(todo_line)
98
+ todo_list.append(todo_task)
84
99
  todo_list.sort(
85
100
  key=lambda task: (
86
101
  task.completed,
@@ -173,51 +188,154 @@ def todo_task_to_line(task: TodoTaskModel) -> str:
173
188
  return " ".join(parts)
174
189
 
175
190
 
176
- def get_visual_todo_list(todo_list: list[TodoTaskModel]) -> str:
177
- if len(todo_list) == 0:
191
+ def get_visual_todo_list(todo_list: list[TodoTaskModel], filter: str) -> str:
192
+ todo_filter = line_to_todo_task(filter)
193
+ filtered_todo_list = []
194
+ for todo_task in todo_list:
195
+ filter_description = todo_filter.description.lower().strip()
196
+ if (
197
+ filter_description != ""
198
+ and filter_description not in todo_task.description.lower()
199
+ ):
200
+ continue
201
+ if not all(context in todo_task.contexts for context in todo_filter.contexts):
202
+ continue
203
+ if not all(project in todo_task.projects for project in todo_filter.projects):
204
+ continue
205
+ if not all(
206
+ key in todo_task.keyval and todo_task.keyval[key] == val
207
+ for key, val in todo_filter.keyval.items()
208
+ ):
209
+ continue
210
+ filtered_todo_list.append(todo_task)
211
+ if len(filtered_todo_list) == 0:
178
212
  return "\n".join(["", " Empty todo list... 🌵🦖", ""])
179
- max_desc_name_length = max(len(todo_task.description) for todo_task in todo_list)
180
- if max_desc_name_length < len("DESCRIPTION"):
181
- max_desc_name_length = len("DESCRIPTION")
213
+ max_desc_length = max(
214
+ len(todo_task.description) for todo_task in filtered_todo_list
215
+ )
216
+ if max_desc_length < len("DESCRIPTION"):
217
+ max_desc_length = len("DESCRIPTION")
218
+ if max_desc_length > 70:
219
+ max_desc_length = 70
220
+ max_additional_info_length = max(
221
+ todo_task.get_additional_info_length() for todo_task in filtered_todo_list
222
+ )
223
+ if max_additional_info_length < len("PROJECT/CONTEXT/OTHERS"):
224
+ max_additional_info_length = len("PROJECT/CONTEXT/OTHERS")
225
+ terminal_width, _ = shutil.get_terminal_size()
182
226
  # Headers
183
227
  results = [
184
- stylize_bold_green(
185
- " ".join(
186
- [
187
- "".ljust(3), # priority
188
- "".ljust(3), # completed
189
- "COMPLETED AT".rjust(14), # completed date
190
- "CREATED AT".rjust(14), # completed date
191
- "DESCRIPTION".ljust(max_desc_name_length),
192
- "PROJECT/CONTEXT/OTHERS",
193
- ]
228
+ stylize_faint(
229
+ get_visual_todo_header(
230
+ terminal_width, max_desc_length, max_additional_info_length
194
231
  )
195
232
  )
196
233
  ]
197
- for todo_task in todo_list:
198
- completed = "[x]" if todo_task.completed else "[ ]"
199
- priority = " " if todo_task.priority is None else f"({todo_task.priority})"
200
- completion_date = stylize_yellow(_date_to_str(todo_task.completion_date))
201
- creation_date = stylize_cyan(_date_to_str(todo_task.creation_date))
202
- description = todo_task.description.ljust(max_desc_name_length)
203
- additions = ", ".join(
204
- [stylize_yellow(f"+{project}") for project in todo_task.projects]
205
- + [stylize_cyan(f"@{context}") for context in todo_task.contexts]
206
- + [stylize_magenta(f"{key}:{val}") for key, val in todo_task.keyval.items()]
207
- )
234
+ for todo_task in filtered_todo_list:
208
235
  results.append(
236
+ get_visual_todo_line(
237
+ terminal_width, max_desc_length, max_additional_info_length, todo_task
238
+ )
239
+ )
240
+ return "\n".join(results)
241
+
242
+
243
+ def get_visual_todo_header(
244
+ terminal_width: int, max_desc_length: int, max_additional_info_length: int
245
+ ) -> str:
246
+ priority = "".ljust(3)
247
+ completed = "".ljust(3)
248
+ completed_at = "COMPLETED AT".rjust(14)
249
+ created_at = "CREATED_AT".ljust(14)
250
+ description = "DESCRIPTION".ljust(min(max_desc_length, 70))
251
+ additional_info = "PROJECT/CONTEXT/OTHERS"
252
+ if terminal_width <= 14 + max_desc_length + max_additional_info_length:
253
+ return " ".join([priority, completed, description])
254
+ if terminal_width <= 36 + max_desc_length + max_additional_info_length:
255
+ return " ".join([priority, completed, description, additional_info])
256
+ return " ".join(
257
+ [priority, completed, completed_at, created_at, description, additional_info]
258
+ )
259
+
260
+
261
+ def get_visual_todo_line(
262
+ terminal_width: int,
263
+ max_desc_length: int,
264
+ max_additional_info_length: int,
265
+ todo_task: TodoTaskModel,
266
+ ) -> str:
267
+ completed = "[x]" if todo_task.completed else "[ ]"
268
+ priority = " " if todo_task.priority is None else f"({todo_task.priority})"
269
+ completed_at = stylize_yellow(_date_to_str(todo_task.completion_date))
270
+ created_at = stylize_cyan(_date_to_str(todo_task.creation_date))
271
+ description = todo_task.description
272
+ if len(description) > max_desc_length:
273
+ description = description[: max_desc_length - 4] + " ..."
274
+ description = description.ljust(max_desc_length)
275
+ description = description[:max_desc_length]
276
+ if todo_task.completed:
277
+ description = stylize_faint(description)
278
+ elif "duration" in todo_task.keyval:
279
+ description = stylize_bold_yellow(description)
280
+ additional_info = ", ".join(
281
+ [stylize_yellow(f"+{project}") for project in todo_task.projects]
282
+ + [stylize_cyan(f"@{context}") for context in todo_task.contexts]
283
+ + [stylize_magenta(f"{key}:{val}") for key, val in todo_task.keyval.items()]
284
+ )
285
+ if terminal_width <= 14 + max_desc_length + max_additional_info_length:
286
+ return " ".join([priority, completed, description])
287
+ if terminal_width <= 36 + max_desc_length + max_additional_info_length:
288
+ return " ".join([priority, completed, description, additional_info])
289
+ return " ".join(
290
+ [priority, completed, completed_at, created_at, description, additional_info]
291
+ )
292
+
293
+
294
+ def get_visual_todo_card(
295
+ todo_task: TodoTaskModel, log_work_list: list[dict[str, str]]
296
+ ) -> str:
297
+ description = todo_task.description
298
+ status = "TODO"
299
+ if todo_task.completed:
300
+ status = "DONE"
301
+ elif "duration" in todo_task.keyval:
302
+ status = "DOING"
303
+ priority = todo_task.priority
304
+ completed_at = (
305
+ _date_to_str(todo_task.completion_date)
306
+ if todo_task.completion_date is not None
307
+ else ""
308
+ )
309
+ created_at = (
310
+ _date_to_str(todo_task.creation_date)
311
+ if todo_task.creation_date is not None
312
+ else ""
313
+ )
314
+ log_work_str = "\n".join(
315
+ [
209
316
  " ".join(
210
317
  [
211
- completed,
212
- priority,
213
- completion_date,
214
- creation_date,
215
- description,
216
- additions,
318
+ stylize_magenta(log_work.get("duration", "").strip().rjust(12)),
319
+ stylize_cyan(log_work.get("start", "").strip().rjust(20)),
320
+ log_work.get("log", "").strip(),
217
321
  ]
218
322
  )
323
+ for log_work in log_work_list
324
+ ]
325
+ )
326
+ detail = [
327
+ f"{'📄 Description'.ljust(16)}: {description}",
328
+ f"{'🎯 Priority'.ljust(16)}: {priority}",
329
+ f"{'📊 Status'.ljust(16)}: {status}",
330
+ f"{'📅 Created at'.ljust(16)}: {created_at}",
331
+ f"{'✅ Completed at'.ljust(16)}: {completed_at}",
332
+ ]
333
+ if log_work_str != "":
334
+ detail.append(
335
+ stylize_faint(" ".join(["Time Spent".rjust(12), "Start".rjust(20), "Log"]))
219
336
  )
220
- return "\n".join(results)
337
+ detail.append(log_work_str)
338
+ return "\n".join(detail)
221
339
 
222
340
 
223
341
  def _date_to_str(date: datetime.date | None) -> str: