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,94 @@
1
+ import sys
2
+ import re
3
+ import logging
4
+ from logging.handlers import RotatingFileHandler
5
+
6
+ from cmdbox.logging_setup.log_config import LogConfig, get_logger
7
+
8
+
9
+ class SecretRedactionFilter(logging.Filter):
10
+ """
11
+ A logging filter to redact sensitive information from log messages.
12
+
13
+ This filter is designed to sanitize log messages by redacting sensitive
14
+ data such as tokens and passwords. The redaction process is applied
15
+ whenever a log record passes through the filter, ensuring that such
16
+ sensitive information is not exposed in logs.
17
+ """
18
+
19
+ def filter(self, record: logging.LogRecord) -> bool:
20
+ if isinstance(record.msg, str):
21
+ record.msg = self._redact(record.msg)
22
+ return True
23
+
24
+ def _redact(self, s: str) -> str:
25
+ s = re.sub(r"(?i)\b(token\s*=\s*)(\S+)", r"\1[REDACTED]", s)
26
+ s = re.sub(r"(?i)\b(password\s*=\s*)(\S+)", r"\1[REDACTED]", s)
27
+ return s
28
+
29
+
30
+ class RunIdFilter(logging.Filter):
31
+ """
32
+ A logging filter that appends a unique run identifier to log records.
33
+
34
+ Attributes:
35
+ run_id (str): The identifier for the current run, which will be
36
+ appended to log records.
37
+ """
38
+
39
+ def __init__(self, run_id: str) -> None:
40
+ super().__init__()
41
+ self.run_id = run_id
42
+
43
+ def filter(self, record: logging.LogRecord) -> bool:
44
+ record.run_id = self.run_id
45
+ return True
46
+
47
+
48
+ def configure_logging(config: LogConfig, run_id: str) -> None:
49
+ """
50
+ Configures logging for the application, setting up a logger with both console and file
51
+ handlers as specified by the provided configuration. Filters and formatters are applied
52
+ to ensure appropriate logging output.
53
+
54
+ Args:
55
+ config (LogConfig): Configuration object specifying the logging settings, including
56
+ log levels, file path, maximum file size, and number of backups.
57
+ run_id (str): Unique identifier for the current run, used to tag log records.
58
+ """
59
+ logger = get_logger()
60
+ logger.setLevel(logging.DEBUG) # capture everything, then filter
61
+ logger.propagate = False
62
+
63
+ for h in list(logger.handlers):
64
+ logger.removeHandler(h)
65
+
66
+ formatter = logging.Formatter(
67
+ fmt="%(asctime)s.%(msecs)03d | %(levelname)s | %(run_id)s | %(name)s | %(message)s",
68
+ datefmt="%Y-%m-%d, %H:%M:%S",
69
+ )
70
+
71
+ secret_redaction_filter = SecretRedactionFilter()
72
+ run_id_filter = RunIdFilter(run_id)
73
+
74
+ # Console handler
75
+ ch = logging.StreamHandler(sys.stderr)
76
+ ch.setLevel(config.console_level)
77
+ ch.setFormatter(formatter)
78
+ ch.addFilter(secret_redaction_filter)
79
+ ch.addFilter(run_id_filter)
80
+ logger.addHandler(ch)
81
+
82
+ # File handler
83
+ if config.file_enabled:
84
+ fh = RotatingFileHandler(
85
+ filename=str(config.file_path),
86
+ maxBytes=int(config.max_bytes),
87
+ backupCount=int(config.backups),
88
+ encoding="utf-8",
89
+ )
90
+ fh.setLevel(config.file_level)
91
+ fh.setFormatter(formatter)
92
+ fh.addFilter(secret_redaction_filter)
93
+ fh.addFilter(run_id_filter)
94
+ logger.addHandler(fh)
@@ -0,0 +1 @@
1
+ """Migration runner package for CmdBox schema versioning."""
@@ -0,0 +1,10 @@
1
+ from cmdbox.exceptions import CmdboxError
2
+
3
+
4
+ class MigrationError(CmdboxError):
5
+ """
6
+ Exception raised when an error occurs during database migration.
7
+ """
8
+
9
+ def __init__(self, message: str) -> None:
10
+ super().__init__(message)
@@ -0,0 +1,127 @@
1
+ import importlib
2
+ import logging
3
+ import pkgutil
4
+ from pathlib import Path
5
+ from datetime import datetime
6
+
7
+ from peewee import SqliteDatabase
8
+
9
+ from cmdbox.migrations.errors import MigrationError
10
+
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ def ensure_migrated(db_path: str) -> None:
16
+ """
17
+ Ensures the database is migrated to the current version. Connects to the SQLite database,
18
+ checks the user version, and applies any necessary migrations to bring the database
19
+ to the current version. If the database version is ahead of the current application
20
+ version, an error is raised.
21
+
22
+ Args:
23
+ db_path: A string representing the path to the SQLite database file.
24
+
25
+ Raises:
26
+ MigrationError: If the database version is greater than the application's
27
+ current version, indicating the application is outdated and needs updating.
28
+ """
29
+ db = SqliteDatabase(db_path)
30
+ db.connect()
31
+ version = get_user_version(db)
32
+ db.close()
33
+
34
+ current_version = get_current_version()
35
+
36
+ if version == 0:
37
+ db.connect()
38
+ set_user_version(db, current_version)
39
+ db.close()
40
+ log.debug("Fresh database stamped at v%d", current_version)
41
+ return
42
+
43
+ if version == current_version:
44
+ return
45
+
46
+ if version > current_version:
47
+ raise MigrationError(
48
+ f"Database version {version} is ahead of the application (v{current_version}). Please update CmdBox."
49
+ )
50
+
51
+ migrations = load_migrations()
52
+ path = Path(db_path)
53
+
54
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
55
+
56
+ for v in range(version, current_version):
57
+ migrate(v, path, migrations, timestamp)
58
+
59
+
60
+ def migrate(version: int, path: Path, migrations: dict, timestamp: str) -> None:
61
+ """
62
+ Migrates a database from one version to the next using the provided migration mapping.
63
+
64
+ Args:
65
+ version (int): The current version of the database.
66
+ path (Path): The file path of the database to migrate.
67
+ migrations (dict): A dictionary where keys are version numbers and values are callable
68
+ migration functions. Each function must accept the current database path and the
69
+ path to the new database as string arguments.
70
+ timestamp (str): A timestamp used for creating backups.
71
+
72
+ Raises:
73
+ MigrationError: If no migration function exists for the current version.
74
+ """
75
+ if version not in migrations:
76
+ raise MigrationError(
77
+ f"No migration found for v{version} to v{version + 1}. The installation may be corrupt."
78
+ )
79
+
80
+ new_path = path.parent / (path.name + ".new")
81
+
82
+ log.info("Migrating database v%d -> v%d...", version, version + 1)
83
+ migrations[version](str(path), str(new_path))
84
+
85
+ new_db = SqliteDatabase(str(new_path))
86
+ new_db.connect()
87
+ set_user_version(new_db, version + 1)
88
+ new_db.close()
89
+
90
+ backup_db(path, version, timestamp)
91
+ new_path.rename(path)
92
+
93
+ log.info("Migration to v%d complete.", version + 1)
94
+
95
+
96
+ def load_migrations() -> dict:
97
+ import cmdbox.migrations as migrations_pkg
98
+
99
+ migrations = {}
100
+ for _, name, _ in pkgutil.iter_modules(migrations_pkg.__path__):
101
+ if not (name.startswith("m") and name[1:].isdigit()):
102
+ continue
103
+ module = importlib.import_module(f"cmdbox.migrations.versions.{name}")
104
+ migrations[module.VERSION] = module.migrate
105
+ return migrations
106
+
107
+
108
+ def get_current_version() -> int:
109
+ migrations = load_migrations()
110
+ return max(migrations.keys(), default=1)
111
+
112
+
113
+ def get_user_version(db: SqliteDatabase) -> int:
114
+ return db.execute_sql("PRAGMA user_version").fetchone()[0]
115
+
116
+
117
+ def set_user_version(db: SqliteDatabase, version: int) -> None:
118
+ db.execute_sql(f"PRAGMA user_version = {version}")
119
+
120
+
121
+ def backup_db(db_path: Path, version: int, timestamp: str) -> Path:
122
+ backup_dir = db_path.parent / "backups"
123
+ backup_dir.mkdir(exist_ok=True)
124
+ bak_path = backup_dir / f"{db_path.stem}_v{version}_{timestamp}.bak"
125
+ db_path.rename(bak_path)
126
+ log.debug("Backed up v%d database to %s", version, bak_path.name)
127
+ return bak_path
File without changes
cmdbox/models.py ADDED
@@ -0,0 +1,165 @@
1
+ from datetime import datetime
2
+
3
+ from peewee import (
4
+ Model,
5
+ CharField,
6
+ IntegerField,
7
+ DateTimeField,
8
+ TextField,
9
+ ForeignKeyField,
10
+ )
11
+ from cmdbox.database import db
12
+
13
+
14
+ class BaseModel(Model):
15
+ """
16
+ BaseModel serves as the foundation for database models.
17
+
18
+ This class extends the 'Model' class from the Peewee library, providing a
19
+ base for all database models within the application. It centralizes the
20
+ database connection, ensuring consistency across all derived models.
21
+
22
+ Attributes:
23
+ Meta (class): Configuration for the database connection, linking models
24
+ to the shared database instance.
25
+ """
26
+
27
+ class Meta:
28
+ database = db
29
+
30
+
31
+ class Command(BaseModel):
32
+ """
33
+ Represents a command model.
34
+
35
+ This class provides a structure for storing and managing command-related data,
36
+ including an alias for the command, its template, timestamps for its creation
37
+ and last update, and a counter for how many times the command has been used.
38
+
39
+ Attributes:
40
+ alias (CharField): Unique identifier for the command.
41
+ template (CharField): The associated template string for the command.
42
+ description (CharField): A description of the command and what it does.
43
+ cwd (CharField): The current working directory for the command execution.
44
+ shell (CharField): The shell to use for command execution.
45
+ env (TextField): Environment variables to set for the command execution.
46
+ timeout (IntegerField): Number of seconds before the process is killed.
47
+ date_created (DateTimeField): Timestamp indicating when the command was created.
48
+ last_updated (DateTimeField): Timestamp indicating when the command was last updated.
49
+ used (IntegerField): Counter representing how many times the command has been used.
50
+ last_used (DateTimeField): Timestamp indicating when the command was last used.
51
+ """
52
+
53
+ alias = CharField(unique=True)
54
+ template = TextField()
55
+ description = TextField(null=True, default=None)
56
+ cwd = CharField(null=True, default=None)
57
+ shell = CharField(null=True, default=None)
58
+ env = TextField(null=True, default=None)
59
+ timeout = IntegerField(null=True, default=None)
60
+ date_created = DateTimeField(default=datetime.now)
61
+ last_updated = DateTimeField(default=datetime.now)
62
+ used = IntegerField(default=0)
63
+ last_used = DateTimeField(null=True, default=None)
64
+
65
+
66
+ class Variable(BaseModel):
67
+ """
68
+ Represents a variable model.
69
+
70
+ A variable model is a key-value pair that can be stored and recalled in commands
71
+ and other variables.
72
+
73
+ Attributes:
74
+ name (CharField): The unique name of the configuration variable.
75
+ value (CharField): The value associated with the configuration variable.
76
+ """
77
+
78
+ name = CharField(unique=True)
79
+ value = CharField()
80
+ date_created = DateTimeField(default=datetime.now)
81
+ last_updated = DateTimeField(default=datetime.now)
82
+
83
+
84
+ class Tag(BaseModel):
85
+ """
86
+ Represents a Tag model with attributes for tagging, description, and timestamps.
87
+
88
+ This class is designed to store tag information as a name and description to allow
89
+ users to organize their commands and variables into categories and to give them a
90
+ convenient way to search for and filter them later.
91
+
92
+ Attributes:
93
+ name (CharField): A unique name identifier for the tag.
94
+ description (TextField): A detailed textual description of the tag.
95
+ date_created (DateTimeField): The timestamp indicating when the tag was created.
96
+ last_updated (DateTimeField): The timestamp indicating the last update for the tag.
97
+ """
98
+
99
+ name = CharField(unique=True)
100
+ description = TextField(null=True, default=None)
101
+ date_created = DateTimeField(default=datetime.now)
102
+ last_updated = DateTimeField(default=datetime.now)
103
+
104
+
105
+ class CommandTag(BaseModel):
106
+ """
107
+ Represents the relationship between commands and tags.
108
+
109
+ This class is used as an intermediary table to establish a many-to-many
110
+ relationship between a command and a tag.
111
+
112
+ Attributes:
113
+ command (ForeignKeyField): Refers to a command associated with a tag.
114
+ tag (ForeignKeyField): Refers to a tag associated with a command.
115
+ date_created (DateTimeField): DateTimeField indicating when the relationship was created.
116
+ """
117
+
118
+ command = ForeignKeyField(Command, backref="tags", on_delete="CASCADE")
119
+ tag = ForeignKeyField(Tag, backref="commands", on_delete="CASCADE")
120
+ date_created = DateTimeField(default=datetime.now)
121
+
122
+ class Meta:
123
+ indexes = ((("command", "tag"), True),)
124
+
125
+
126
+ class VariableTag(BaseModel):
127
+ """
128
+ Represents a many-to-many relationship between Variable and Tag models.
129
+
130
+ This class serves as an intermediary table to establish a many-to-many relationship
131
+ between a variable and a tag.
132
+
133
+ Attributes:
134
+ variable: (ForeignKeyField) Refers to a variable associated with a tag.
135
+ tag: (ForeignKeyField) Refers to a tag associated with a variable.
136
+ date_created (DateTimeField): DateTimeField indicating when the relationship was created.
137
+ """
138
+
139
+ variable = ForeignKeyField(Variable, backref="tags", on_delete="CASCADE")
140
+ tag = ForeignKeyField(Tag, backref="variables", on_delete="CASCADE")
141
+ date_created = DateTimeField(default=datetime.now)
142
+
143
+ class Meta:
144
+ indexes = ((("variable", "tag"), True),)
145
+
146
+
147
+ class CommandHistory(BaseModel):
148
+
149
+ id = TextField(primary_key=True) # uuid4().hex - 32 char, no dashes
150
+ alias = CharField() # alias as invoked, not a FK
151
+ template = TextField() # raw template at time of run
152
+ resolved = TextField() # fully resolved command string
153
+ variables_used = TextField(null=True) # JSON: {"name": "Homer"} or null
154
+ exit_code = IntegerField(null=True)
155
+ ran_at = DateTimeField(default=datetime.now)
156
+
157
+ class Meta:
158
+ table_name = "command_history"
159
+ indexes = (
160
+ (("alias",), False),
161
+ (("ran_at",), False),
162
+ )
163
+
164
+
165
+ ALL_MODELS = [Command, Variable, Tag, CommandTag, VariableTag, CommandHistory]
File without changes
@@ -0,0 +1,181 @@
1
+ from typing import Sequence, Generic, TypeVar, Type
2
+
3
+ from peewee import Model, fn, IntegrityError, Node
4
+
5
+ M = TypeVar("M", bound=Model)
6
+
7
+
8
+ class BaseRepository(Generic[M]):
9
+ model: Type[M]
10
+
11
+ def _search(
12
+ self,
13
+ query: str,
14
+ secondary_ordering: str,
15
+ fields: str | Sequence[str] | None = None,
16
+ limit: int = 25,
17
+ ) -> list[M]:
18
+ """
19
+ Searches for commands matching the given query across specified fields.
20
+
21
+ This function performs a case-insensitive search for the query in the specified
22
+ fields of the Command model. It calculates a relevance score for each match
23
+ based on the position of the query within the fields and sorts the results by
24
+ relevance. Relevance is determined by a weighted score with the highest weight
25
+ given to most occurrences and the second part of the score given to how early
26
+ the search term occurs in the field.
27
+
28
+ Args:
29
+ query (str): The search query to match in the specified fields.
30
+ secondary_ordering (str): The field to sort the results by, after relevance.
31
+ fields (str | Sequence[str] | None): The fields to search within. By
32
+ default, searches within "name" and "description". Can be a single field
33
+ name as a string or a sequence of field names.
34
+ limit (int): The maximum number of results to return. Default is 25.
35
+
36
+ Returns:
37
+ list[Command]: A list of Command objects matching the search query, sorted
38
+ by relevance.
39
+
40
+ Raises:
41
+ ValueError: If any provided field does not exist on the Command model.
42
+ """
43
+ if not query:
44
+ return []
45
+ if isinstance(fields, str):
46
+ fields = self._get_sequence(fields)
47
+ if fields is None or len(fields) == 0:
48
+ return []
49
+ or_clauses = []
50
+ relevance_parts = []
51
+ query_lower = query.lower()
52
+ for field_name in fields:
53
+ if not hasattr(self.model, field_name) or field_name == "":
54
+ raise ValueError(f"Invalid field: {field_name}")
55
+ field = getattr(self.model, field_name)
56
+ or_clauses.append(fn.LOWER(field).contains(query_lower))
57
+ first_pos = fn.INSTR(fn.lower(field), query_lower)
58
+ occurrences = (
59
+ fn.LENGTH(field) - fn.LENGTH(fn.REPLACE(field, query_lower, ""))
60
+ ) / len(query_lower)
61
+ score = (occurrences * 1000) - first_pos
62
+ relevance_parts.append(score)
63
+
64
+ condition = or_clauses[0]
65
+ for clause in or_clauses[1:]:
66
+ condition |= clause
67
+
68
+ if len(relevance_parts) > 1:
69
+ relevance = fn.MIN(*relevance_parts).alias("relevance")
70
+ else:
71
+ relevance = relevance_parts[0].alias("relevance")
72
+
73
+ query_obj = (
74
+ self.model.select(self.model, relevance)
75
+ .where(condition)
76
+ .order_by(
77
+ relevance.desc(),
78
+ getattr(self.model, secondary_ordering),
79
+ )
80
+ .limit(limit)
81
+ )
82
+ return list(query_obj)
83
+
84
+ def _resolve_order_token(self, token: str) -> Node:
85
+ """
86
+ Resolves the ordering token for determining the sorting order of a field.
87
+
88
+ This method takes an ordering token, identifies whether the sorting is
89
+ ascending or descending, and ensures the token corresponds to a valid
90
+ model attribute for sorting.
91
+
92
+ Args:
93
+ token (str): The ordering token indicating the field for sorting. A
94
+ token starting with '-' indicates descending sort, otherwise it
95
+ is ascending. The token must correspond to a valid attribute
96
+ in `self.model`.
97
+
98
+ Returns:
99
+ Model: An object representing the ordering of the specified field
100
+ in ascending or descending order.
101
+
102
+ Raises:
103
+ ValueError: If the token is empty or if it references an invalid
104
+ field in the model.
105
+ """
106
+ if not token:
107
+ raise ValueError("Empty order_by token.")
108
+ desc = token.startswith("-")
109
+ field_name = token[1:] if desc else token
110
+ try:
111
+ field = getattr(self.model, field_name)
112
+ except AttributeError:
113
+ raise ValueError(f"Invalid order_by field: {token}")
114
+ return field.desc() if desc else field.asc()
115
+
116
+ def _resolve_ordering(self, order_by: str | Sequence[str]) -> Sequence[Node]:
117
+ """
118
+ Resolves ordering tokens based on the specified order_by input.
119
+
120
+ This method processes the input order_by parameter, which could either
121
+ be a single string of comma-separated tokens or a sequence of strings.
122
+ It ensures the tokens are parsed and resolved appropriately, returning a
123
+ sequence of resolved models.
124
+
125
+ Args:
126
+ order_by: A string containing comma-separated tokens, or a sequence
127
+ of strings representing ordering keys.
128
+
129
+ Returns:
130
+ Sequence[Model]: A sequence of resolved models based on the specified
131
+ ordering tokens.
132
+
133
+ Raises:
134
+ ValueError: If the order_by sequence is empty.
135
+ """
136
+ tokens = self._get_sequence(order_by)
137
+ if not tokens:
138
+ raise ValueError("Empty order_by sequence.")
139
+ return [self._resolve_order_token(token) for token in tokens]
140
+
141
+ def _get_sequence(self, items: str | Sequence[str]) -> list[str]:
142
+ """
143
+ Processes a string or sequence of strings into a list of stripped strings.
144
+
145
+ If the input is a single string, it is split by commas, and whitespace around each
146
+ split item is stripped. If the input is already a sequence of strings, it is converted
147
+ into a list without any modifications.
148
+
149
+ Args:
150
+ items (str | Sequence[str]): A string to be split into a list of strings or an
151
+ existing sequence of strings.
152
+
153
+ Returns:
154
+ list[str]: A list of processed strings.
155
+
156
+ Raises:
157
+ ValueError: If the input is neither a string nor a sequence of strings.
158
+ """
159
+ if isinstance(items, str):
160
+ return [item.strip() for item in items.split(",") if item.strip()]
161
+ else:
162
+ return [item.strip() for item in items if item.strip()]
163
+
164
+ def _is_unique_name_violation(self, exc: IntegrityError) -> bool:
165
+ """
166
+ Checks if the exception indicates a unique constraint violation.
167
+
168
+ This method analyzes the provided IntegrityError to determine if the
169
+ error was caused by a UNIQUE constraint violation on a field of the
170
+ database table associated with the model.
171
+
172
+ Args:
173
+ exc (IntegrityError): The IntegrityError exception to be checked.
174
+
175
+ Returns:
176
+ bool: True if the exception indicates a unique constraint violation;
177
+ otherwise, False.
178
+ """
179
+ msg = str(exc)
180
+ table = self.model._meta.table_name
181
+ return "UNIQUE constraint failed" in msg and f"{table}.name" in msg