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,91 @@
1
+ from typing import Sequence
2
+
3
+ from peewee import IntegrityError
4
+
5
+ from .base_repository import BaseRepository
6
+ from .errors import (
7
+ NameConflictError,
8
+ UnknownNameError,
9
+ ValidationError,
10
+ UpdateError,
11
+ UnknownTagError,
12
+ )
13
+ from .validators import TagValidator
14
+ from cmdbox.models import Tag
15
+
16
+
17
+ class TagRepository(BaseRepository[Tag]):
18
+ model = Tag
19
+
20
+ def __init__(self, validator: TagValidator | None = None):
21
+ self.validator = validator or TagValidator()
22
+
23
+ def create(self, name: str, description: str | None = None) -> Tag:
24
+ name = name.strip() if name else None
25
+ self.validator.validate_create(name=name, description=description)
26
+ try:
27
+ return Tag.create(name=name, description=description)
28
+ except IntegrityError as exc:
29
+ if name is not None and self._is_unique_name_violation(exc):
30
+ raise NameConflictError(name=name) from exc
31
+ raise
32
+
33
+ def get_by_name(self, name: str) -> Tag | None:
34
+ name = name.lower()
35
+ tag = Tag.get_or_none(Tag.name == name)
36
+ if tag is None:
37
+ raise UnknownNameError(name=name)
38
+ return tag
39
+
40
+ def get_by_id(self, tag_id: int) -> Tag | None:
41
+ tag = Tag.get_or_none(Tag.id == tag_id)
42
+ if tag is None:
43
+ raise UnknownTagError(str(tag_id))
44
+ return tag
45
+
46
+ def update(self, tag: Tag, **fields) -> Tag | None:
47
+ if not tag:
48
+ raise UpdateError("Tag not found.")
49
+ if not fields:
50
+ raise UpdateError("No fields provided for update.")
51
+
52
+ if "name" in fields and fields.get("name") is not None:
53
+ fields["name"] = fields.get("name").strip()
54
+
55
+ self.validator.validate_update(
56
+ name=fields.get("name", tag.name),
57
+ description=fields.get("description", tag.description),
58
+ )
59
+ try:
60
+ for key, value in fields.items():
61
+ if not hasattr(tag, key):
62
+ raise ValidationError(f"Invalid field: {key}")
63
+ if value is not None:
64
+ setattr(tag, key, value)
65
+ tag.save()
66
+ return tag
67
+ except IntegrityError as exc:
68
+ name = fields.get("name", "")
69
+ if name is not None and self._is_unique_name_violation(exc):
70
+ raise NameConflictError(name=name) from exc
71
+ raise
72
+
73
+ def list_all(
74
+ self, order_by: str | Sequence[str] = "name", limit: int = 25
75
+ ) -> list[Tag]:
76
+ ordering = self._resolve_ordering(order_by)
77
+ return list(Tag.select().order_by(*ordering).limit(limit))
78
+
79
+ def search(
80
+ self,
81
+ query: str,
82
+ fields: str | Sequence[str] | None = ("name", "description"),
83
+ limit: int = 25,
84
+ ) -> list[Tag]:
85
+ return self._search(query, "name", fields, limit=limit)
86
+
87
+ def delete(self, tag: Tag) -> bool:
88
+ if not tag:
89
+ return False
90
+ tag.delete_instance()
91
+ return True
@@ -0,0 +1,256 @@
1
+ from dataclasses import dataclass
2
+
3
+ from .errors import ValidationError
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class CommandValidatorConfig:
8
+ """Configuration for command validation rules."""
9
+
10
+ reserved_aliases: frozenset[str] = frozenset(
11
+ {
12
+ "help",
13
+ "init",
14
+ "list",
15
+ "ls",
16
+ "add",
17
+ "rm",
18
+ "delete",
19
+ }
20
+ )
21
+ max_alias_length: int = 100
22
+ max_description_length: int = 1000
23
+
24
+
25
+ class CommandValidator:
26
+
27
+ def __init__(self, config: CommandValidatorConfig | None = None):
28
+ self.config = config or CommandValidatorConfig()
29
+
30
+ def validate_create(
31
+ self,
32
+ *,
33
+ alias: str,
34
+ template: str,
35
+ description: str | None = None,
36
+ ) -> None:
37
+ """
38
+ Validates the parameters for creating a Command. Ensures that the provided alias,
39
+ template, and description meet the required conditions and do not contain
40
+ self-referencing patterns.
41
+
42
+ Args:
43
+ alias (str): The alias to validate.
44
+ template (str): The template to validate.
45
+ description (str | None): The optional description to validate.
46
+ """
47
+ self.validate_alias(alias)
48
+ self.validate_template(template)
49
+ self.validate_description(description)
50
+ self.validate_no_self_reference(alias, template)
51
+
52
+ def validate_update(
53
+ self,
54
+ *,
55
+ alias: str | None = None,
56
+ template: str | None = None,
57
+ description: str | None = None,
58
+ ):
59
+ """
60
+ Validates various update parameters, ensuring they meet the required conditions
61
+ and constraints. This method can validate a combination of alias, template,
62
+ and description, applying specific checks for each provided parameter.
63
+
64
+ Args:
65
+ alias (str | None): An optional alias to validate. If provided, it will
66
+ be checked using the `self.validate_alias` method.
67
+ template (str | None): An optional template to validate. If provided, it
68
+ will be checked using the `self.validate_template` method.
69
+ description (str | None): An optional description to validate. If
70
+ provided, it will be checked using the `self.validate_description`
71
+ method.
72
+ """
73
+ if alias is not None:
74
+ self.validate_alias(alias)
75
+ if template is not None:
76
+ self.validate_template(template)
77
+ if description is not None:
78
+ self.validate_description(description)
79
+ if alias is not None and template is not None:
80
+ self.validate_no_self_reference(alias, template)
81
+
82
+ def validate_alias(self, alias: str) -> None:
83
+ if not alias:
84
+ raise ValidationError("Alias cannot be empty.")
85
+
86
+ stripped = alias.strip()
87
+ if not stripped:
88
+ raise ValidationError("Alias cannot contain only whitespace.")
89
+
90
+ if " " in stripped:
91
+ raise ValidationError("Alias cannot contain spaces.")
92
+
93
+ if stripped in self.config.reserved_aliases:
94
+ raise ValidationError(f"Alias '{stripped}' is reserved.")
95
+
96
+ if len(stripped) > self.config.max_alias_length:
97
+ raise ValidationError(
98
+ f"Alias '{stripped}' is too long. Maximum length is {self.config.max_alias_length}."
99
+ )
100
+
101
+ def validate_template(self, template: str) -> None:
102
+ if not template:
103
+ raise ValidationError("Template cannot be empty.")
104
+
105
+ stripped = template.strip()
106
+ if not stripped:
107
+ raise ValidationError("Template cannot contain only whitespace.")
108
+
109
+ def validate_description(self, description: str | None) -> None:
110
+ if not description:
111
+ return
112
+ if len(description) > self.config.max_description_length:
113
+ raise ValidationError(
114
+ f"Description is too long. Maximum length is {self.config.max_description_length}."
115
+ )
116
+
117
+ def validate_no_self_reference(self, alias: str, template: str) -> None:
118
+ self_reference = f"<{alias}>"
119
+ if self_reference in template:
120
+ raise ValidationError(
121
+ f"Template cannot contain self-reference: {self_reference}"
122
+ )
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class VariableValidatorConfig:
127
+ """Configuration for variable validation rules."""
128
+
129
+ reserved_names: frozenset[str] = frozenset(
130
+ {
131
+ "help",
132
+ "init",
133
+ "list",
134
+ "ls",
135
+ "add",
136
+ "rm",
137
+ "delete",
138
+ # Reserved because they are options to the run command and can conflict when dynamically called
139
+ "preview",
140
+ "cwd",
141
+ "env",
142
+ "capture",
143
+ "shell",
144
+ "emit",
145
+ "verbose",
146
+ }
147
+ )
148
+ max_name_length: int = 100
149
+
150
+
151
+ class VariableValidator:
152
+
153
+ def __init__(self, config: VariableValidatorConfig | None = None):
154
+ self.config = config or VariableValidatorConfig()
155
+
156
+ def validate_create(self, name: str, value: str) -> None:
157
+ self.validate_name(name)
158
+ self.validate_value(value)
159
+
160
+ def validate_update(self, name: str | None, value: str | None) -> None:
161
+ if name is not None:
162
+ self.validate_name(name)
163
+ if value is not None:
164
+ self.validate_value(value)
165
+
166
+ def validate_name(self, name: str) -> None:
167
+ if not name:
168
+ raise ValidationError("Variable name cannot be empty.")
169
+
170
+ stripped = name.strip()
171
+ if not stripped:
172
+ raise ValidationError("Variable name cannot contain only whitespace.")
173
+
174
+ if " " in stripped:
175
+ raise ValidationError("Variable name cannot contain spaces.")
176
+
177
+ if stripped in self.config.reserved_names:
178
+ raise ValidationError(f"Variable name '{stripped}' is reserved.")
179
+
180
+ if len(stripped) > self.config.max_name_length:
181
+ raise ValidationError(
182
+ f"Variable name '{stripped}' is too long. Maximum length is {self.config.max_name_length}."
183
+ )
184
+
185
+ def validate_value(self, value: str) -> None:
186
+ if value is None:
187
+ raise ValidationError("Variable value cannot be None.")
188
+
189
+ def validate_no_self_reference(self, name: str, value: str) -> None:
190
+ self_reference = f"<{name}>"
191
+ if self_reference in value:
192
+ raise ValidationError(
193
+ f"Variable value cannot contain self-reference: {self_reference}"
194
+ )
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class TagValidatorConfig:
199
+ """Configuration for variable validation rules."""
200
+
201
+ reserved_names: frozenset[str] = frozenset(
202
+ {
203
+ "help",
204
+ "init",
205
+ "list",
206
+ "ls",
207
+ "add",
208
+ "rm",
209
+ "delete",
210
+ }
211
+ )
212
+ max_name_length: int = 100
213
+ max_description_length: int = 1000
214
+
215
+
216
+ class TagValidator:
217
+
218
+ def __init__(self, config: TagValidatorConfig | None = None):
219
+ self.config = config or TagValidatorConfig()
220
+
221
+ def validate_create(self, name: str, description: str | None) -> None:
222
+ self.validate_name(name)
223
+ self.validate_description(description)
224
+
225
+ def validate_update(self, name: str | None, description: str | None) -> None:
226
+ if name is not None:
227
+ self.validate_name(name)
228
+ if description is not None:
229
+ self.validate_description(description)
230
+
231
+ def validate_name(self, name: str) -> None:
232
+ if not name:
233
+ raise ValidationError("Variable name cannot be empty.")
234
+
235
+ stripped = name.strip()
236
+ if not stripped:
237
+ raise ValidationError("Variable name cannot contain only whitespace.")
238
+
239
+ if " " in stripped:
240
+ raise ValidationError("Variable name cannot contain spaces.")
241
+
242
+ if stripped in self.config.reserved_names:
243
+ raise ValidationError(f"Variable name '{stripped}' is reserved.")
244
+
245
+ if len(stripped) > self.config.max_name_length:
246
+ raise ValidationError(
247
+ f"Variable name '{stripped}' is too long. Maximum length is {self.config.max_name_length}."
248
+ )
249
+
250
+ def validate_description(self, description: str | None) -> None:
251
+ if not description:
252
+ return
253
+ if len(description) > self.config.max_description_length:
254
+ raise ValidationError(
255
+ f"Description is too long. Maximum length is {self.config.max_description_length}."
256
+ )
@@ -0,0 +1,324 @@
1
+ from typing import Sequence
2
+
3
+ from peewee import IntegrityError
4
+
5
+ from .base_repository import BaseRepository
6
+ from .errors import (
7
+ ValidationError,
8
+ NameConflictError,
9
+ UnknownTagError,
10
+ TagAttachError,
11
+ TagDetachError,
12
+ UpdateError,
13
+ UnknownVariableError,
14
+ )
15
+ from .validators import VariableValidator
16
+ from .results import TagAttachResult, TagDetachResult
17
+ from cmdbox.database import db
18
+ from cmdbox.models import Variable, Tag, VariableTag
19
+
20
+
21
+ class VariableRepository(BaseRepository[Variable]):
22
+ model = Variable
23
+
24
+ def __init__(self, validator: VariableValidator | None = None):
25
+ self.validator = validator or VariableValidator()
26
+
27
+ def create(self, name: str, value: str) -> Variable:
28
+ """
29
+ Validates and creates a new Variable object based on provided input parameters.
30
+
31
+ Args:
32
+ name (str): Unique identifier for the variable to be created.
33
+ value (str): The value that will be subbed for the name when executing.
34
+
35
+ Returns:
36
+ Variable: The created Variable object.
37
+
38
+ Raises:
39
+ NameConflictError: If a variable with the provided name already exists
40
+ and causes a unique constraint violation during creation.
41
+ IntegrityError: If any database integrity issue occurs during the creation.
42
+ """
43
+ name = name.strip() if name else None
44
+ self.validator.validate_create(name=name, value=value)
45
+ try:
46
+ return Variable.create(name=name, value=value)
47
+ except IntegrityError as exc:
48
+ if name is not None and self._is_unique_name_violation(exc):
49
+ raise NameConflictError(name=name) from exc
50
+ raise
51
+
52
+ def get_by_name(self, name: str) -> Variable | None:
53
+ """
54
+ Retrieves a variable instance by its name.
55
+
56
+ Converts the provided name to lowercase, searches for a variable with the
57
+ matching name in the database, and retrieves it. This function returns None
58
+ if no variable with the specified name is found.
59
+
60
+ Args:
61
+ name (str): The name of the variable to retrieve.
62
+
63
+ Returns:
64
+ Variable | None: The variable object if found, otherwise None.
65
+ """
66
+ name = name.lower()
67
+ return Variable.get_or_none(Variable.name == name)
68
+
69
+ def get_by_id(self, var_id: int) -> Variable:
70
+ var = Variable.get_or_none(Variable.id == var_id)
71
+ if var is None:
72
+ raise UnknownVariableError(var_id=var_id)
73
+ return var
74
+
75
+ def update(self, variable: Variable, **fields) -> Variable:
76
+ """
77
+ Updates an existing variable based on the provided name and fields.
78
+
79
+ This method retrieves a variable by its name and updates its fields with the
80
+ provided values. It validates the updates, ensuring field integrity and
81
+ uniqueness. If the variable doesn't exist or an update violates constraints,
82
+ appropriate actions are taken.
83
+
84
+ Args:
85
+ variable (Variable): The variable to update.
86
+ **fields: Arbitrary keyword arguments representing the fields to update.
87
+ Supported fields include 'name' and 'value'.
88
+
89
+ Returns:
90
+ Variable: The updated variable object if successful, or None if the
91
+ variable is not found.
92
+
93
+ Raises:
94
+ ValidationError: If any of the provided fields are invalid or do not exist
95
+ on a variable.
96
+ NameConflictError: If the new name conflicts with an existing variable's
97
+ name.
98
+ IntegrityError: If there is a general integrity constraint violation during
99
+ the update process.
100
+ """
101
+ if not variable:
102
+ raise UpdateError("No variable provided for update.")
103
+ if not fields:
104
+ raise UpdateError("No fields provided for update.")
105
+
106
+ if "name" in fields and fields.get("name") is not None:
107
+ fields["name"] = fields.get("name").strip()
108
+
109
+ self.validator.validate_update(
110
+ name=fields.get("name", variable.name),
111
+ value=fields.get("value", variable.value),
112
+ )
113
+
114
+ try:
115
+ for key, value in fields.items():
116
+ if not hasattr(variable, key):
117
+ raise ValidationError(f"Invalid field: {key}")
118
+ if value is not None:
119
+ setattr(variable, key, value)
120
+ variable.save()
121
+ return variable
122
+ except IntegrityError as exc:
123
+ name = fields.get("name", "")
124
+ if name is not None and self._is_unique_name_violation(exc):
125
+ raise NameConflictError(name=name) from exc
126
+ raise
127
+
128
+ def add_tags(self, variable: Variable, tags: Sequence[Tag]) -> TagAttachResult:
129
+ """
130
+ Attach tags to a variable identified by its name.
131
+
132
+ This function associates tags with a specified variable. If the tags already
133
+ exist for the variable, they are added to an existing list. Otherwise, new tags
134
+ are created and linked to the variable. If no tags are provided, it returns
135
+ immediately with empty results. In case of a database integrity issue, an
136
+ appropriate error is raised.
137
+
138
+ Args:
139
+ variable (Variable): The variable to which the tags are to be attached.
140
+ tags (Sequence[Tag]): A collection of tags to be attached to the
141
+ variable.
142
+
143
+ Returns:
144
+ TagAttachResult: An object containing lists of newly added tags and
145
+ tags that already existed.
146
+
147
+ Raises:
148
+ TagAttachError: If there is an issue attaching the tags to the variable,
149
+ typically due to database integrity constraints.
150
+ """
151
+ if not tags:
152
+ return TagAttachResult(added=[], existing=[])
153
+ try:
154
+ with db.atomic():
155
+ added = []
156
+ existing = []
157
+ for tag in tags:
158
+ var_tag, created = VariableTag.get_or_create(
159
+ variable=variable, tag=tag
160
+ )
161
+ if created:
162
+ added.append(tag.name)
163
+ else:
164
+ existing.append(tag.name)
165
+ return TagAttachResult(added=added, existing=existing)
166
+ except UnknownTagError:
167
+ raise
168
+ except IntegrityError as exc:
169
+ raise TagAttachError("Could not attach tags to variable.") from exc
170
+
171
+ def remove_tags(self, variable: Variable, tags: Sequence[Tag]) -> TagDetachResult:
172
+ """
173
+ Removes tags from a variable identified by the provided name. The method first validates
174
+ the tags to ensure they exist in the database, then attempts to remove the associations
175
+ between the variable and the respective tags. If a tag is not attached to the variable, it
176
+ is recorded in the `not_attached` list, while successfully removed tags are recorded in
177
+ the `removed` list. Any errors during detachment raise a `TagDetachError`.
178
+
179
+ Args:
180
+ variable (Variable): The variable from which the tags will be detached.
181
+ tags (Sequence[Tag]): A list of tags to be detached from the variable.
182
+
183
+ Returns:
184
+ TagDetachResult: Object that contains two lists:
185
+ - `removed`: A list of successfully detached tags.
186
+ - `not_attached`: A list of tags that were not associated with the variable.
187
+
188
+ Raises:
189
+ TagDetachError: If the detachment process encounters an issue, such as database
190
+ integrity errors.
191
+ """
192
+ if not variable and not tags:
193
+ return TagDetachResult(removed=[], not_attached=[])
194
+ removed = []
195
+ not_attached = []
196
+ try:
197
+ with db.atomic():
198
+ for tag in tags:
199
+ deleted = (
200
+ VariableTag.delete()
201
+ .where(
202
+ (VariableTag.variable == variable)
203
+ & (VariableTag.tag == tag)
204
+ )
205
+ .execute()
206
+ )
207
+ if deleted:
208
+ removed.append(tag.name)
209
+ else:
210
+ not_attached.append(tag.name)
211
+ except IntegrityError as exc:
212
+ raise TagDetachError("Could not detach tags from variable.") from exc
213
+ except AttributeError:
214
+ raise TagDetachError("Invalid tag provided.")
215
+ return TagDetachResult(removed=removed, not_attached=not_attached)
216
+
217
+ def list_all(
218
+ self, order_by: str | Sequence[str] = "name", limit: int = 25
219
+ ) -> list[Variable]:
220
+ """
221
+ Lists all variables from the database, optionally ordered by specified fields.
222
+
223
+ Args:
224
+ order_by (str | Sequence[str]): A string or sequence of strings indicating the field(s)
225
+ by which the variables should be ordered. Defaults to "name".
226
+ limit (int): The maximum number of variables to return. Defaults to 25.
227
+
228
+ Returns:
229
+ List[Variable]: A list of Variable objects retrieved from the database,
230
+ sorted based on the specified ordering criteria.
231
+ """
232
+ ordering = self._resolve_ordering(order_by)
233
+ return list(Variable.select().order_by(*ordering).limit(limit))
234
+
235
+ def list_by_tag(
236
+ self,
237
+ tags: Sequence[Tag],
238
+ order_by: str | Sequence[str] = "name",
239
+ limit: int = 25,
240
+ ) -> list[Variable]:
241
+ """
242
+ Fetches a list of variables filtered by specified tags and ordered by specific
243
+ criteria.
244
+
245
+ This method retrieves a list of `Variable` objects associated with the tags
246
+ provided in the `tags` argument. The results are optionally ordered based on the
247
+ `order_by` field and limited by the `limit` argument.
248
+
249
+ Args:
250
+ tags (Sequence[Tag]): A list of Tag objects to filter variables by.
251
+ order_by (str | Sequence[str]): Criteria to order the resulting variable
252
+ list. Defaults to "name".
253
+ limit (int): The maximum number of variables to return. Defaults to 25.
254
+
255
+ Returns:
256
+ list[Variable]: A list of `Variable` objects matching the tags and ordered as
257
+ requested.
258
+
259
+ Raises:
260
+ UnknownTagError: If one or more provided tags do not exist in the database.
261
+ """
262
+ ordering = self._resolve_ordering(order_by)
263
+ return list(
264
+ Variable.select()
265
+ .join(VariableTag)
266
+ .where(VariableTag.tag << tags)
267
+ .order_by(*ordering)
268
+ .distinct()
269
+ .limit(limit)
270
+ )
271
+
272
+ def search(
273
+ self,
274
+ query: str,
275
+ fields: str | Sequence[str] | None = ("name", "value"),
276
+ limit: int = 25,
277
+ ) -> list[Variable]:
278
+ """
279
+ Searches for variables matching the given query across specified fields.
280
+
281
+ This function performs a case-insensitive search for the query in the specified
282
+ fields of the Variable model. It calculates a relevance score for each match
283
+ based on the occurrence count and position of the query within the fields and
284
+ sorts the results by relevance.
285
+ Args:
286
+ query (str): The search query to match in the specified fields.
287
+ fields (str | Sequence[str] | None): The fields to search within. By
288
+ default, searches within "name". Can be a single field name as a string
289
+ or a sequence of field names.
290
+ limit (int): The maximum number of results to return. Defaults to 25.
291
+
292
+ Returns:
293
+ list[Variable]: A list of Variable objects matching the search query, sorted
294
+ by relevance.
295
+
296
+ Raises:
297
+ ValueError: If any provided field does not exist on the Variable model.
298
+ """
299
+ return self._search(
300
+ query, secondary_ordering="name", fields=fields, limit=limit
301
+ )
302
+
303
+ def delete(self, variable: Variable) -> bool:
304
+ """
305
+ Deletes the variable with the specified name.
306
+
307
+ Args:
308
+ variable (Variable): The variable to delete.
309
+
310
+ Returns:
311
+ bool: True if the variable was deleted, False otherwise.
312
+ """
313
+ if not variable:
314
+ return False
315
+ variable.delete_instance()
316
+ return True
317
+
318
+ def _is_unique_variable_tag_violation(self, exc: IntegrityError) -> bool:
319
+ msg = str(exc)
320
+ return (
321
+ "UNIQUE constraint failed" in msg
322
+ and "variabletag.variable_id" in msg
323
+ and "variabletag.tag_id" in msg
324
+ )
File without changes