cmdbox-cli 1.0.0__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 (112) hide show
  1. cmdbox/__init__.py +0 -0
  2. cmdbox/cli/__init__.py +0 -0
  3. cmdbox/cli/app.py +125 -0
  4. cmdbox/cli/commands/__init__.py +0 -0
  5. cmdbox/cli/commands/alias_fallback.py +102 -0
  6. cmdbox/cli/commands/command_crud.py +429 -0
  7. cmdbox/cli/commands/command_run.py +255 -0
  8. cmdbox/cli/commands/history.py +109 -0
  9. cmdbox/cli/commands/init.py +54 -0
  10. cmdbox/cli/commands/settings.py +62 -0
  11. cmdbox/cli/commands/tag_crud.py +277 -0
  12. cmdbox/cli/commands/variable_crud.py +349 -0
  13. cmdbox/cli/common/__init__.py +0 -0
  14. cmdbox/cli/common/errors.py +58 -0
  15. cmdbox/cli/common/update_fields.py +88 -0
  16. cmdbox/cli/completions/__init__.py +0 -0
  17. cmdbox/cli/completions/commands.py +26 -0
  18. cmdbox/cli/completions/fields.py +31 -0
  19. cmdbox/cli/completions/tags.py +24 -0
  20. cmdbox/cli/completions/variables.py +26 -0
  21. cmdbox/cli/handlers/__init__.py +0 -0
  22. cmdbox/cli/handlers/command_handlers.py +357 -0
  23. cmdbox/cli/handlers/common_handlers.py +15 -0
  24. cmdbox/cli/handlers/history_handlers.py +94 -0
  25. cmdbox/cli/handlers/init_handler.py +127 -0
  26. cmdbox/cli/handlers/run_handler.py +178 -0
  27. cmdbox/cli/handlers/settings_handler.py +59 -0
  28. cmdbox/cli/handlers/tag_handlers.py +220 -0
  29. cmdbox/cli/handlers/variable_handlers.py +272 -0
  30. cmdbox/cli/prompts/__init__.py +0 -0
  31. cmdbox/cli/prompts/completers.py +161 -0
  32. cmdbox/cli/prompts/prompts.py +108 -0
  33. cmdbox/cli/prompts/validators.py +46 -0
  34. cmdbox/cli/ui/__init__.py +0 -0
  35. cmdbox/cli/ui/console.py +31 -0
  36. cmdbox/cli/ui/editor.py +141 -0
  37. cmdbox/cli/ui/presenters/__init__.py +0 -0
  38. cmdbox/cli/ui/presenters/app_presenter.py +8 -0
  39. cmdbox/cli/ui/presenters/command_presenter.py +168 -0
  40. cmdbox/cli/ui/presenters/history_presenter.py +83 -0
  41. cmdbox/cli/ui/presenters/init_instructions.py +52 -0
  42. cmdbox/cli/ui/presenters/init_presenter.py +57 -0
  43. cmdbox/cli/ui/presenters/result_presenter.py +144 -0
  44. cmdbox/cli/ui/presenters/settings_presenter.py +130 -0
  45. cmdbox/cli/ui/presenters/tag_presenter.py +97 -0
  46. cmdbox/cli/ui/presenters/variable_presenter.py +103 -0
  47. cmdbox/cli/ui/primitives.py +410 -0
  48. cmdbox/cli/ui/theme.py +43 -0
  49. cmdbox/cli/ui/theme_builder.py +49 -0
  50. cmdbox/common/__init__.py +0 -0
  51. cmdbox/common/io.py +34 -0
  52. cmdbox/container.py +156 -0
  53. cmdbox/core/__init__.py +0 -0
  54. cmdbox/core/fields.py +48 -0
  55. cmdbox/core/paths.py +52 -0
  56. cmdbox/database.py +65 -0
  57. cmdbox/exceptions.py +10 -0
  58. cmdbox/init/__init__.py +0 -0
  59. cmdbox/init/detect.py +82 -0
  60. cmdbox/init/integrations/bash.sh +10 -0
  61. cmdbox/init/integrations/cmd.bat +14 -0
  62. cmdbox/init/integrations/fish.fish +11 -0
  63. cmdbox/init/integrations/powershell.ps1 +14 -0
  64. cmdbox/init/integrations/zsh.sh +10 -0
  65. cmdbox/init/io.py +68 -0
  66. cmdbox/init/specs.py +54 -0
  67. cmdbox/logging_setup/__init__.py +0 -0
  68. cmdbox/logging_setup/log_config.py +123 -0
  69. cmdbox/logging_setup/log_decorators.py +40 -0
  70. cmdbox/logging_setup/log_handlers.py +94 -0
  71. cmdbox/migrations/__init__.py +1 -0
  72. cmdbox/migrations/errors.py +10 -0
  73. cmdbox/migrations/runner.py +127 -0
  74. cmdbox/migrations/versions/__init__.py +0 -0
  75. cmdbox/models.py +165 -0
  76. cmdbox/repositories/__init__.py +0 -0
  77. cmdbox/repositories/base_repository.py +181 -0
  78. cmdbox/repositories/command_repository.py +391 -0
  79. cmdbox/repositories/errors.py +120 -0
  80. cmdbox/repositories/history_repository.py +155 -0
  81. cmdbox/repositories/results.py +37 -0
  82. cmdbox/repositories/tag_repository.py +91 -0
  83. cmdbox/repositories/validators.py +256 -0
  84. cmdbox/repositories/variable_repository.py +324 -0
  85. cmdbox/resolve/__init__.py +0 -0
  86. cmdbox/resolve/errors.py +65 -0
  87. cmdbox/resolve/lookup.py +137 -0
  88. cmdbox/resolve/resolver.py +402 -0
  89. cmdbox/resolve/type_defs.py +96 -0
  90. cmdbox/runtime/__init__.py +0 -0
  91. cmdbox/runtime/executor.py +454 -0
  92. cmdbox/runtime/results.py +25 -0
  93. cmdbox/runtime/shell.py +90 -0
  94. cmdbox/services/__init__.py +0 -0
  95. cmdbox/services/command_services.py +261 -0
  96. cmdbox/services/errors.py +37 -0
  97. cmdbox/services/field_selection.py +162 -0
  98. cmdbox/services/history_service.py +68 -0
  99. cmdbox/services/run_service.py +204 -0
  100. cmdbox/services/tag_services.py +134 -0
  101. cmdbox/services/variable_services.py +224 -0
  102. cmdbox/settings/__init__.py +0 -0
  103. cmdbox/settings/models.py +129 -0
  104. cmdbox/settings/settings_repository.py +36 -0
  105. cmdbox/settings/settings_service.py +144 -0
  106. cmdbox/version.py +1 -0
  107. cmdbox_cli-1.0.0.dist-info/METADATA +125 -0
  108. cmdbox_cli-1.0.0.dist-info/RECORD +112 -0
  109. cmdbox_cli-1.0.0.dist-info/WHEEL +5 -0
  110. cmdbox_cli-1.0.0.dist-info/entry_points.txt +2 -0
  111. cmdbox_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
  112. cmdbox_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,391 @@
1
+ import json
2
+ from typing import Sequence
3
+ from datetime import datetime
4
+
5
+ from peewee import IntegrityError
6
+
7
+ from .base_repository import BaseRepository
8
+ from .validators import CommandValidator
9
+ from .errors import (
10
+ ValidationError,
11
+ AliasConflictError,
12
+ UnknownAliasError,
13
+ TagAttachError,
14
+ TagDetachError,
15
+ UpdateError,
16
+ UnknownCommandError,
17
+ )
18
+ from .results import TagAttachResult, TagDetachResult
19
+ from cmdbox.database import db
20
+ from cmdbox.models import Command, Tag, CommandTag
21
+
22
+
23
+ class CommandRepository(BaseRepository[Command]):
24
+
25
+ model = Command
26
+
27
+ def __init__(self, validator: CommandValidator | None = None):
28
+ self.validator = validator or CommandValidator()
29
+
30
+ def create(
31
+ self,
32
+ alias: str,
33
+ template: str,
34
+ description: str | None = None,
35
+ cwd: str | None = None,
36
+ shell: str | None = None,
37
+ env: dict[str, str] | None = None,
38
+ timeout: int | None = None,
39
+ ) -> Command:
40
+ """
41
+ Validates and creates a new Command object based on provided input parameters.
42
+
43
+ Args:
44
+ alias (str): Unique identifier for the command to be created.
45
+ template (str): Template string associated with the command.
46
+ description (str | None): Optional description of the command.
47
+ cwd (str | None): Current working directory for command execution.
48
+ shell (str | None): Shell to use for command execution.
49
+ env (dict[str, str] | None): Environment variables to set for command execution.
50
+ timeout (int | None): Number of seconds before the process is killed.
51
+
52
+ Returns:
53
+ Command: The created Command object.
54
+
55
+ Raises:
56
+ AliasConflictError: If the provided alias already exists and causes a
57
+ unique constraint violation during creation.
58
+ IntegrityError: If any database integrity issue occurs during the creation.
59
+ """
60
+ alias = alias.strip() if alias else None
61
+ self.validator.validate_create(
62
+ alias=alias, template=template, description=description
63
+ )
64
+ try:
65
+ return Command.create(
66
+ alias=alias,
67
+ template=template,
68
+ description=description,
69
+ cwd=cwd,
70
+ shell=shell,
71
+ env=json.dumps(env) if env else None,
72
+ timeout=timeout,
73
+ )
74
+ except IntegrityError as exc:
75
+ if alias is not None and self._is_unique_alias_violation(exc):
76
+ raise AliasConflictError(alias=alias) from exc
77
+ raise
78
+
79
+ def get_by_alias(self, alias: str) -> Command:
80
+ """
81
+ Retrieves a Command instance by its alias.
82
+
83
+ This method searches for a Command instance using the given alias. The alias
84
+ is converted to lowercase before searching. If no matching Command instance
85
+ is found, an UnknownAliasError is raised.
86
+
87
+ Args:
88
+ alias (str): The alias of the Command being searched for.
89
+
90
+ Returns:
91
+ Command: The Command instance that matches the provided alias.
92
+
93
+ Raises:
94
+ UnknownAliasError: If no Command is found with the provided alias.
95
+ """
96
+ alias = alias.lower()
97
+ cmd = Command.get_or_none(Command.alias == alias)
98
+ if cmd is None:
99
+ raise UnknownAliasError(alias=alias)
100
+ return cmd
101
+
102
+ def get_by_id(self, cmd_id: int) -> Command:
103
+ """
104
+ Retrieves a command by its ID.
105
+
106
+ Args:
107
+ cmd_id (int): The ID of the command to retrieve.
108
+
109
+ Returns:
110
+ Command: The Command instance with the specified ID.
111
+
112
+ Raises:
113
+ UnknownCommandError: If no Command is found with the provided ID.
114
+ """
115
+ cmd = Command.get_or_none(Command.id == cmd_id)
116
+ if cmd is None:
117
+ raise UnknownCommandError(cmd_id=cmd_id)
118
+ return cmd
119
+
120
+ def update(self, command: Command, **fields) -> Command:
121
+ """
122
+ Updates an existing command based on the provided alias and fields.
123
+
124
+ This method retrieves a command by its alias and updates its fields with the
125
+ provided values. It validates the updates, ensuring field integrity and
126
+ uniqueness. If the command doesn't exist or an update violates constraints,
127
+ appropriate actions are taken.
128
+
129
+ Args:
130
+ command (Command): The command to update.
131
+ **fields: Arbitrary keyword arguments representing the fields to update.
132
+ Supported fields include 'alias', 'template', and 'description'.
133
+
134
+ Returns:
135
+ Command: The updated command object if successful.
136
+
137
+ Raises:
138
+ ValidationError: If any of the provided fields are invalid or do not exist
139
+ on the command.
140
+ AliasConflictError: If the new alias conflicts with an existing command's
141
+ alias.
142
+ IntegrityError: If there is a general integrity constraint violation during
143
+ the update process.
144
+ """
145
+ if not command:
146
+ raise UpdateError("No command provided for update.")
147
+ if not fields:
148
+ raise UpdateError("No fields provided for update.")
149
+
150
+ # Strip whitespace from alias field if alias field is supplied
151
+ if "alias" in fields and fields.get("alias") is not None:
152
+ fields["alias"] = fields.get("alias").strip()
153
+
154
+ self.validator.validate_update(
155
+ alias=fields.get("alias", command.alias),
156
+ template=fields.get("template", command.template),
157
+ description=fields.get("description", None),
158
+ )
159
+
160
+ if "env" in fields:
161
+ fields["env"] = json.dumps(fields.get("env")) if fields.get("env") else None
162
+
163
+ try:
164
+ for key, value in fields.items():
165
+ if not hasattr(command, key):
166
+ raise ValidationError(f"Invalid field: {key}")
167
+ if value is not None:
168
+ setattr(command, key, value)
169
+ command.save()
170
+ return command
171
+ except IntegrityError as exc:
172
+ alias = fields.get("alias", "")
173
+ if alias is not None and self._is_unique_alias_violation(exc):
174
+ raise AliasConflictError(alias=alias) from exc
175
+ raise
176
+
177
+ def record_use(self, command_id: int) -> None:
178
+ """
179
+ Records the use of a specific command by updating its usage count and the timestamp of
180
+ its last use in the database.
181
+
182
+ Args:
183
+ command_id (int): The unique identifier of the command to update.
184
+ """
185
+ Command.update(
186
+ used=Command.used + 1,
187
+ last_used=datetime.now(),
188
+ ).where(Command.id == command_id).execute()
189
+
190
+ def add_tags(self, command: Command, tags: Sequence[Tag]) -> TagAttachResult:
191
+ """
192
+ Attach tags to a command identified by its alias.
193
+
194
+ This function associates tags with a specified command. If the tags already
195
+ exist for the command, they are added to an existing list. Otherwise, new tags
196
+ are created and linked to the command. If no tags are provided, it returns
197
+ immediately with empty results. In case of a database integrity issue, an
198
+ appropriate error is raised.
199
+
200
+ Args:
201
+ command (Command): The command to which the tags are to
202
+ be attached.
203
+ tags (Sequence[Tag]): A collection of tags to be attached to the
204
+ command.
205
+
206
+ Returns:
207
+ TagAttachResult: An object containing lists of newly added tags and
208
+ tags that already existed.
209
+
210
+ Raises:
211
+ TagAttachError: If there is an issue attaching the tags to the command,
212
+ typically due to database integrity constraints.
213
+ """
214
+ if not tags:
215
+ return TagAttachResult(added=[], existing=[])
216
+ try:
217
+ with db.atomic():
218
+ added = []
219
+ existing = []
220
+ for tag in tags:
221
+ cmd_tag, created = CommandTag.get_or_create(
222
+ command=command, tag=tag
223
+ )
224
+ if created:
225
+ added.append(tag.name)
226
+ else:
227
+ existing.append(tag.name)
228
+ return TagAttachResult(added=added, existing=existing)
229
+ except IntegrityError as exc:
230
+ raise TagAttachError("Could not attach tags to command.") from exc
231
+
232
+ def remove_tags(self, command: Command, tags: Sequence[Tag]) -> TagDetachResult:
233
+ """
234
+ Removes tags from a command identified by the provided alias. The method first validates
235
+ the tags to ensure they exist in the database, then attempts to remove the associations
236
+ between the command and the respective tags. If a tag is not attached to the command, it
237
+ is recorded in the `not_attached` list, while successfully removed tags are recorded in
238
+ the `removed` list. Any errors during detachment raise a `TagDetachError`.
239
+
240
+ Args:
241
+ command (Command): The command from which the tags will be detached.
242
+ tags (Sequence[Tag]): A list of tags to be detached from the command.
243
+
244
+ Returns:
245
+ TagDetachResult: Object that contains two lists:
246
+ - `removed`: A list of successfully detached tags.
247
+ - `not_attached`: A list of tags that were not associated with the command.
248
+
249
+ Raises:
250
+ TagDetachError: If the detachment process encounters an issue, such as database
251
+ integrity errors.
252
+ """
253
+ if not tags:
254
+ return TagDetachResult(removed=[], not_attached=[])
255
+ removed = []
256
+ not_attached = []
257
+ try:
258
+ with db.atomic():
259
+ for tag in tags:
260
+ deleted = (
261
+ CommandTag.delete()
262
+ .where(
263
+ (CommandTag.command == command) & (CommandTag.tag == tag)
264
+ )
265
+ .execute()
266
+ )
267
+ if deleted:
268
+ removed.append(tag.name)
269
+ else:
270
+ not_attached.append(tag.name)
271
+ except IntegrityError as exc:
272
+ raise TagDetachError("Could not detach tags from command.") from exc
273
+ except AttributeError:
274
+ raise TagDetachError("Invalid tag provided.")
275
+ return TagDetachResult(removed=removed, not_attached=not_attached)
276
+
277
+ def list_all(
278
+ self, order_by: str | Sequence[str] = "alias", limit: int = 25
279
+ ) -> list[Command]:
280
+ """
281
+ Lists all Command objects from the database, optionally ordered by specified fields.
282
+
283
+ Args:
284
+ order_by (str | Sequence[str]): A string or sequence of strings indicating the field(s)
285
+ by which the Command objects should be ordered. Defaults to "name".
286
+ limit (int): The maximum number of Command objects to return. Defaults to 25.
287
+
288
+ Returns:
289
+ list[Command]: A list of Command objects retrieved from the database,
290
+ sorted based on the specified ordering criteria.
291
+ """
292
+ ordering = self._resolve_ordering(order_by)
293
+ return list(Command.select().order_by(*ordering).limit(limit))
294
+
295
+ def list_by_tag(
296
+ self,
297
+ tags: Sequence[Tag],
298
+ order_by: str | Sequence[str] = "alias",
299
+ limit: int = 25,
300
+ ) -> list[Command]:
301
+ """
302
+ Fetches a list of commands filtered by specified tags and ordered by specific
303
+ criteria.
304
+
305
+ This method retrieves a list of `Command` objects associated with the tags
306
+ provided in the `tags` parameter. The results can be customized through
307
+ ordering and limited in number.
308
+
309
+ Args:
310
+ tags (Sequence[Tag]): A sequence of tag objects used to filter the commands.
311
+ Only commands associated with these tags will be retrieved.
312
+ order_by (str | Sequence[str], optional): Specifies the criteria to order
313
+ the commands. Defaults to "alias".
314
+ limit (int, optional): The maximum number of commands to retrieve. Defaults
315
+ to 25.
316
+
317
+ Returns:
318
+ list[Command]: A list of `Command` objects that meet the specified filters
319
+ and ordering criteria.
320
+ """
321
+ if not tags:
322
+ return []
323
+ commands = (
324
+ Command.select().join(CommandTag).where(CommandTag.tag << tags).distinct()
325
+ )
326
+ ordering = self._resolve_ordering(order_by)
327
+ return list(commands.order_by(*ordering).limit(limit))
328
+
329
+ def search(
330
+ self,
331
+ query: str,
332
+ fields: str | Sequence[str] | None = ("alias", "template", "description"),
333
+ limit: int = 25,
334
+ ) -> list[Command]:
335
+ """
336
+ Searches for commands matching the given query across specified fields.
337
+
338
+ This function performs a case-insensitive search for the query in the specified
339
+ fields of the Command model. It calculates a relevance score for each match
340
+ based on the occurrence count and position of the query within the fields and
341
+ sorts the results by relevance.
342
+
343
+ Args:
344
+ query (str): The search query to match in the specified fields.
345
+ fields (str | Sequence[str] | None): The fields to search within. By
346
+ default, searches within "name" and "description". Can be a single field
347
+ name as a string or a sequence of field names.
348
+ limit (int): The maximum number of results to return. Defaults to 25.
349
+
350
+ Returns:
351
+ list[Command]: A list of Command objects matching the search query, sorted
352
+ by relevance.
353
+
354
+ Raises:
355
+ ValueError: If any provided field does not exist on the Command model.
356
+ """
357
+ return self._search(query, "alias", fields, limit)
358
+
359
+ def delete(self, command: Command) -> bool:
360
+ """
361
+ Deletes the command with the specified alias.
362
+
363
+ Args:
364
+ command (Command): The command to delete.
365
+
366
+ Returns:
367
+ bool: True if the command was deleted, False otherwise.
368
+ """
369
+ if not command:
370
+ return False
371
+ command.delete_instance()
372
+ return True
373
+
374
+ def _is_unique_alias_violation(self, exc: IntegrityError) -> bool:
375
+ """
376
+ Checks if the exception indicates a unique constraint violation.
377
+
378
+ This method analyzes the provided IntegrityError to determine if the
379
+ error was caused by a UNIQUE constraint violation on a field of the
380
+ database table associated with the model.
381
+
382
+ Args:
383
+ exc (IntegrityError): The IntegrityError exception to be checked.
384
+
385
+ Returns:
386
+ bool: True if the exception indicates a unique constraint violation;
387
+ otherwise, False.
388
+ """
389
+ msg = str(exc)
390
+ table = self.model._meta.table_name
391
+ return "UNIQUE constraint failed" in msg and f"{table}.alias" in msg
@@ -0,0 +1,120 @@
1
+ from cmdbox.exceptions import CmdboxError
2
+
3
+
4
+ class UnknownCommandError(CmdboxError):
5
+
6
+ def __init__(self, cmd_id: int) -> None:
7
+ super().__init__(f"Command with ID '{cmd_id}' not found.")
8
+
9
+
10
+ class UnknownAliasError(CmdboxError):
11
+ """
12
+ Exception raised when an unknown alias is encountered.
13
+
14
+ This error is raised when a requested alias is not found within the
15
+ storage system.
16
+
17
+ Attributes:
18
+ alias (str): The alias that was not found.
19
+ """
20
+
21
+ def __init__(self, alias: str) -> None:
22
+ super().__init__(f"Alias '{alias}' not found.")
23
+
24
+
25
+ class AliasConflictError(CmdboxError):
26
+ """
27
+ Indicates a conflict caused by an already existing alias.
28
+
29
+ This exception is raised when attempting to create or use an alias that
30
+ already exists.
31
+
32
+ Attributes:
33
+ alias (str): The alias string that caused the conflict.
34
+ """
35
+
36
+ def __init__(self, alias: str) -> None:
37
+ super().__init__(f"Alias '{alias}' already exists.")
38
+
39
+
40
+ class UnknownVariableError(CmdboxError):
41
+
42
+ def __init__(self, var_id: int) -> None:
43
+ super().__init__(f"Variable with ID '{var_id}' not found.")
44
+
45
+
46
+ class UnknownNameError(CmdboxError):
47
+ """
48
+ Exception raised for accessing an unknown variable.
49
+
50
+ This exception is raised when a requested variable is not found in
51
+ the storage system.
52
+
53
+ Attributes:
54
+ variable (str): The name of the variable that was not found.
55
+ """
56
+
57
+ def __init__(self, name: str) -> None:
58
+ super().__init__(f"Variable name '{name}' not found.")
59
+
60
+
61
+ class NameConflictError(CmdboxError):
62
+ """
63
+ Exception caused by an already existing variable.
64
+
65
+ This exception is raised when attempting to create or use a variable that
66
+ already exists.
67
+
68
+ Attributes:
69
+ name (str): The name of the variable that caused the conflict.
70
+ """
71
+
72
+ def __init__(self, name: str) -> None:
73
+ super().__init__(f"Name '{name}' already exists.")
74
+
75
+
76
+ class UpdateError(CmdboxError):
77
+
78
+ def __init__(self, message: str | None = None) -> None:
79
+ super().__init__(f"Failed to update: {message}")
80
+
81
+
82
+ class UnknownTagError(CmdboxError):
83
+
84
+ def __init__(self, tag_name: str) -> None:
85
+ super().__init__(f"Tag '{tag_name}' not found.")
86
+
87
+
88
+ class TagAttachError(CmdboxError):
89
+ pass
90
+
91
+
92
+ class TagDetachError(CmdboxError):
93
+ pass
94
+
95
+
96
+ class ValidationError(CmdboxError):
97
+ """
98
+ Represents an error related to invalid data.
99
+
100
+ This class is used to handle exceptions that occur due to invalid data.
101
+ Invalid data is data that technically can be stored in the database, but
102
+ is against the use principles allowed by the application.
103
+ ex: Creating a command with an empty alias or template.
104
+ """
105
+
106
+ pass
107
+
108
+
109
+ class UnknownHistoryEntryError(CmdboxError):
110
+
111
+ def __init__(self, ref: str):
112
+ super().__init__(f"No history entry found matching '{ref}'.")
113
+
114
+
115
+ class AmbiguousHistoryIdEntryError(CmdboxError):
116
+
117
+ def __init__(self, prefix: str):
118
+ super().__init__(
119
+ f"ID prefix '{prefix}' matches multiple history entries. Use more ID characters."
120
+ )
@@ -0,0 +1,155 @@
1
+ import json
2
+ import uuid
3
+ from datetime import datetime
4
+
5
+ from cmdbox.models import CommandHistory
6
+ from cmdbox.repositories.errors import (
7
+ UnknownHistoryEntryError,
8
+ AmbiguousHistoryIdEntryError,
9
+ )
10
+
11
+
12
+ class HistoryRepository:
13
+
14
+ def record(
15
+ self,
16
+ *,
17
+ alias: str,
18
+ template: str,
19
+ resolved: str,
20
+ variables_used: dict[str, str] | None,
21
+ exit_code: int | None,
22
+ limit: int | None,
23
+ ) -> CommandHistory:
24
+ """
25
+ Records a command execution in the history and applies retention rules
26
+ to maintain the history within a specified limit.
27
+
28
+ This function creates a new entry in the command history by storing
29
+ details about the executed command, including its alias, the template
30
+ used, resolved result, variables involved, and its exit code. It then
31
+ applies retention policies for the alias based on the provided limit.
32
+
33
+ Args:
34
+ alias (str): The alias of the command being recorded.
35
+ template (str): The template of the command executed.
36
+ resolved (str): The resolved command after variable substitution.
37
+ variables_used (dict[str, str] | None): The variables used in the
38
+ command execution. Defaults to None if no variables were used.
39
+ exit_code (int | None): The exit code of the command. Defaults to
40
+ None if the command has no exit code.
41
+ limit (int | None): The retention limit for the history of the
42
+ specified alias. If None, no limit is applied.
43
+
44
+ Returns:
45
+ CommandHistory: The newly created command history entry.
46
+ """
47
+ entry = CommandHistory.create(
48
+ id=uuid.uuid4().hex,
49
+ alias=alias,
50
+ template=template,
51
+ resolved=resolved,
52
+ variables_used=json.dumps(variables_used) if variables_used else None,
53
+ exit_code=exit_code,
54
+ ran_at=datetime.now(),
55
+ )
56
+ self._apply_retention(alias, limit)
57
+ return entry
58
+
59
+ def get_by_id(self, ref: str) -> CommandHistory:
60
+ """
61
+ Retrieves a `CommandHistory` entry by its ID or a prefix of the ID.
62
+
63
+ This method fetches an entry from the `CommandHistory` database table using the provided
64
+ ID or ID prefix. It ensures that one and only one match exists for the given reference.
65
+ If no match is found, or if multiple matches are found, specific exceptions are raised.
66
+
67
+ Args:
68
+ ref (str): The ID or prefix of the command history entry to retrieve.
69
+
70
+ Returns:
71
+ CommandHistory: The command history entry corresponding to the provided reference.
72
+
73
+ Raises:
74
+ UnknownHistoryEntryError: If no command history entry matches the given reference.
75
+ AmbiguousHistoryIdEntryError: If multiple command history entries match the given
76
+ prefix, making the reference ambiguous.
77
+ """
78
+ matches = list(
79
+ CommandHistory.select().where(CommandHistory.id.startswith(ref)).limit(2)
80
+ )
81
+ if not matches:
82
+ raise UnknownHistoryEntryError(ref=ref)
83
+ if len(matches) > 1:
84
+ raise AmbiguousHistoryIdEntryError(prefix=ref)
85
+ return matches[0]
86
+
87
+ def get_recent(
88
+ self, alias: str | None = None, limit: int = 25
89
+ ) -> list[CommandHistory]:
90
+ """
91
+ Fetches the most recent command history records, optionally filtered by alias and limited
92
+ to a specified number of entries.
93
+
94
+ This method queries the CommandHistory records in descending order of their `ran_at`
95
+ timestamps, which represent the time the commands were executed. If the `alias` parameter
96
+ is provided, the results are filtered to include only those records where the alias matches
97
+ the provided value. The number of returned records can be limited by the `limit` parameter.
98
+ If `limit` is 0 or negative, no results will be retrieved.
99
+
100
+ Args:
101
+ alias: Optional. The alias name to filter the command history records.
102
+ limit: The maximum number of command history records to return. Defaults
103
+ to 25. If set to 0 or a negative number, no records will be retrieved.
104
+
105
+ Returns:
106
+ list[CommandHistory]: A list of `CommandHistory` objects representing the
107
+ queried command history records.
108
+ """
109
+ query = CommandHistory.select().order_by(CommandHistory.ran_at.desc())
110
+ if alias:
111
+ query = query.where(CommandHistory.alias == alias)
112
+ if limit > 0:
113
+ query = query.limit(limit)
114
+ return list(query)
115
+
116
+ def delete_by_id(self, history_id: str) -> bool:
117
+ entry = self.get_by_id(history_id)
118
+ entry.delete_instance()
119
+ return True
120
+
121
+ def clear(self, alias: str | None = None) -> int:
122
+ query = CommandHistory.delete()
123
+ if alias:
124
+ query = query.where(CommandHistory.alias == alias)
125
+ return query.execute()
126
+
127
+ def _apply_retention(self, alias: str, limit: int | None) -> None:
128
+ """
129
+ Applies retention logic by limiting the number of recent command history
130
+ entries for the specified alias. Deletes older command history entries
131
+ exceeding the given limit.
132
+
133
+ Args:
134
+ alias (str): The alias to filter command history entries.
135
+ limit (int | None): The maximum number of recent entries to retain. If
136
+ the limit is less than or equal to zero, no action is performed.
137
+ """
138
+ if limit is None or limit <= 0:
139
+ return
140
+ keep_ids = [
141
+ row.id
142
+ for row in CommandHistory.select(CommandHistory.id)
143
+ .where(CommandHistory.alias == alias)
144
+ .order_by(CommandHistory.ran_at.desc())
145
+ .limit(limit)
146
+ ]
147
+ if len(keep_ids) >= limit:
148
+ (
149
+ CommandHistory.delete()
150
+ .where(
151
+ (CommandHistory.alias == alias)
152
+ & (CommandHistory.id.not_in(keep_ids))
153
+ )
154
+ .execute()
155
+ )
@@ -0,0 +1,37 @@
1
+ from dataclasses import dataclass
2
+ from typing import List
3
+
4
+
5
+ @dataclass
6
+ class TagAttachResult:
7
+ """
8
+ Represents the result of attaching tags.
9
+
10
+ This class holds information about successfully added tags and tags
11
+ that were already present. It helps in distinguishing between newly
12
+ added tags and pre-existing tags when managing tag assignments.
13
+
14
+ Attributes:
15
+ added (List[str]): List of tags that were newly added.
16
+ existing (List[str]): List of tags that were already present.
17
+ """
18
+
19
+ added: List[str]
20
+ existing: List[str]
21
+
22
+
23
+ @dataclass
24
+ class TagDetachResult:
25
+ """
26
+ Represents the result of detaching tags.
27
+
28
+ This class holds information about tags that were successfully removed
29
+ and tags that were not attached in the first place to be removed.
30
+
31
+ Attributes:
32
+ removed (List[str]): List of tags that were successfully removed.
33
+ not_attached (List[str]): List of tags that were not attached in the first place.
34
+ """
35
+
36
+ removed: List[str]
37
+ not_attached: List[str]