stark-engine 4.3.0__tar.gz → 4.4.0__tar.gz

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 (64) hide show
  1. {stark_engine-4.3.0 → stark_engine-4.4.0}/PKG-INFO +3 -2
  2. {stark_engine-4.3.0 → stark_engine-4.4.0}/pyproject.toml +4 -2
  3. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/command.py +16 -23
  4. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/commands_context.py +9 -3
  5. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/commands_context_processor.py +6 -2
  6. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/commands_manager.py +8 -3
  7. stark_engine-4.4.0/stark/core/health_check.py +70 -0
  8. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/parsing.py +118 -25
  9. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/patterns/pattern.py +0 -1
  10. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/processors/search_processor.py +21 -7
  11. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/processors/spacy_ner_processor.py +16 -5
  12. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/types/object.py +7 -2
  13. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/types/slots.py +22 -2
  14. stark_engine-4.4.0/stark/core/types/string.py +9 -0
  15. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/types/word.py +4 -0
  16. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/general/json_encoder.py +1 -1
  17. stark_engine-4.4.0/stark/general/localisation/__init__.py +5 -0
  18. stark_engine-4.4.0/stark/general/localisation/language_code.py +13 -0
  19. stark_engine-4.4.0/stark/general/localisation/locale_string.py +159 -0
  20. stark_engine-4.4.0/stark/general/localisation/localizable_string.py +40 -0
  21. stark_engine-4.4.0/stark/general/localisation/localizer.py +105 -0
  22. stark_engine-4.4.0/stark/general/localisation/strings.py +45 -0
  23. stark_engine-4.3.0/stark/core/health_check.py +0 -51
  24. stark_engine-4.3.0/stark/core/types/string.py +0 -5
  25. {stark_engine-4.3.0 → stark_engine-4.4.0}/LICENSE.md +0 -0
  26. {stark_engine-4.3.0 → stark_engine-4.4.0}/README.md +0 -0
  27. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/__init__.py +0 -0
  28. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/__init__.py +0 -0
  29. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/patterns/__init__.py +0 -0
  30. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/patterns/rules.py +0 -0
  31. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/processors/__init__.py +0 -0
  32. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/types/__init__.py +0 -0
  33. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/core/types/location.py +0 -0
  34. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/general/blockage_detector.py +0 -0
  35. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/general/cache.py +0 -0
  36. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/general/classproperty.py +0 -0
  37. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/general/dependencies.py +0 -0
  38. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/interfaces/gcloud.py +0 -0
  39. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/interfaces/protocols.py +0 -0
  40. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/interfaces/silero.py +0 -0
  41. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/interfaces/vosk.py +0 -0
  42. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/common/span.py +0 -0
  43. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/dictionary/!examples.py +0 -0
  44. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/dictionary/__init__.py +0 -0
  45. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/dictionary/dictionary.py +0 -0
  46. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/dictionary/models.py +0 -0
  47. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/dictionary/nl_dictionary_name.py +0 -0
  48. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/dictionary/storage/__init__.py +0 -0
  49. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/dictionary/storage/storage_memory.py +0 -0
  50. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/dictionary/storage/storage_sqlite.py +0 -0
  51. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/levenshtein/__init__.py +0 -0
  52. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/levenshtein/levenshtein.pyi +0 -0
  53. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/levenshtein/levenshtein.pyx +0 -0
  54. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/phonetic/simplephone.py +0 -0
  55. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/phonetic/transcription/__init__.py +0 -0
  56. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/phonetic/transcription/epitran.py +0 -0
  57. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/phonetic/transcription/espeak.py +0 -0
  58. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/phonetic/transcription/ipa2lat.py +0 -0
  59. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/phonetic/transcription/protocol.py +0 -0
  60. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/sliding_window_parser.py +0 -0
  61. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/tools/strtools.py +0 -0
  62. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/voice_assistant/__init__.py +0 -0
  63. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/voice_assistant/mode.py +0 -0
  64. {stark_engine-4.3.0 → stark_engine-4.4.0}/stark/voice_assistant/voice_assistant.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stark-engine
3
- Version: 4.3.0
3
+ Version: 4.4.0
4
4
  Summary: S.T.A.R.K - Speech and Text Algorithmic Recognition Kit. Modern framework for creating powerfull voice assistants.
5
5
  License: CC BY-NC-SA 4.0
6
6
  License-File: LICENSE.md
@@ -19,7 +19,8 @@ Provides-Extra: silero
19
19
  Provides-Extra: sound
20
20
  Provides-Extra: spacy
21
21
  Provides-Extra: vosk
22
- Requires-Dist: asyncer (>=0.0.2,<0.0.3)
22
+ Requires-Dist: anyio (>=4.0.0,<5.0.0)
23
+ Requires-Dist: asyncer (>=0.0.8,<1.0.0)
23
24
  Requires-Dist: google-cloud-texttospeech (>=2.14.1,<3.0.0) ; extra == "gcloud" or extra == "all"
24
25
  Requires-Dist: numpy (>=2.3.3,<3.0.0) ; extra == "silero" or extra == "all"
25
26
  Requires-Dist: pydantic (>=2.0.0,<3.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "stark-engine"
3
- version = "4.3.0"
3
+ version = "4.4.0"
4
4
  description = "S.T.A.R.K - Speech and Text Algorithmic Recognition Kit. Modern framework for creating powerfull voice assistants."
5
5
  authors = ["MarkParker5 <mark@parker-programs.com>"]
6
6
  license = "CC BY-NC-SA 4.0"
@@ -39,7 +39,8 @@ keywords = [
39
39
  [tool.poetry.dependencies]
40
40
  python = ">=3.12,<3.15"
41
41
  pydantic = "^2.0.0"
42
- asyncer = "^0.0.2"
42
+ asyncer = ">=0.0.8,<1.0.0"
43
+ anyio = ">=4.0.0,<5.0.0"
43
44
  numpy = "^2.3.3"
44
45
 
45
46
  # Optional dependencies
@@ -85,6 +86,7 @@ coverage = "^7.11.0"
85
86
  faker = "^37.11.0"
86
87
  pympler = "^1.1"
87
88
  psutil = "^7.1.1"
89
+ trio = "^0.33.0"
88
90
 
89
91
  [build-system]
90
92
  requires = ["poetry-core"]
@@ -29,12 +29,11 @@ import asyncer
29
29
  from pydantic import BaseModel, Field
30
30
 
31
31
  from ..general.classproperty import classproperty
32
+ from ..general.localisation import LanguageCode, LocalizableString
32
33
  from .patterns import Pattern
33
34
 
34
35
  ResponseOptions = (
35
- Optional["Response"]
36
- | Generator[Optional["Response"], None, None]
37
- | AsyncGenerator[Optional["Response"], None]
36
+ Optional["Response"] | Generator[Optional["Response"], None, None] | AsyncGenerator[Optional["Response"], None]
38
37
  )
39
38
  AwaitResponse = Awaitable[ResponseOptions]
40
39
  AsyncCommandRunner = Callable[..., AwaitResponse]
@@ -44,16 +43,21 @@ CommandRunner = TypeVar("CommandRunner", bound=SyncCommandRunner | AsyncCommandR
44
43
 
45
44
  class Command(Generic[CommandRunner]):
46
45
  name: str
47
- pattern: Pattern
46
+ patterns: dict[str, Pattern]
48
47
  _runner: CommandRunner
49
48
 
50
- def __init__(self, name: str, pattern: Pattern, runner: CommandRunner):
51
- assert isinstance(pattern, Pattern)
49
+ def __init__(self, name: str, patterns: dict[str, Pattern], runner: CommandRunner):
50
+ assert patterns and all(isinstance(p, Pattern) for p in patterns.values())
52
51
  self.name = name
53
- self.pattern = pattern
52
+ self.patterns = patterns
54
53
  self._runner = runner
55
54
  update_wrapper(self, runner)
56
55
 
56
+ def get_pattern(self, language_code: LanguageCode) -> Pattern:
57
+ if language_code in self.patterns:
58
+ return self.patterns[language_code]
59
+ return self.patterns["base"]
60
+
57
61
  def run(
58
62
  self,
59
63
  parameters_dict: dict[str, Any] | None = None,
@@ -74,9 +78,7 @@ class Command(Generic[CommandRunner]):
74
78
 
75
79
  runner: AsyncCommandRunner
76
80
 
77
- if inspect.iscoroutinefunction(self._runner) or inspect.isasyncgen(
78
- self._runner
79
- ):
81
+ if inspect.iscoroutinefunction(self._runner) or inspect.isasyncgen(self._runner):
80
82
  # async functions (coroutines) and async generators are remain as is
81
83
  runner = cast(AsyncCommandRunner, self._runner)
82
84
  else:
@@ -84,21 +86,12 @@ class Command(Generic[CommandRunner]):
84
86
  # async generators are not supported yet by asyncer.asyncify (https://github.com/tiangolo/asyncer/discussions/86)
85
87
  runner = asyncer.asyncify(cast(SyncCommandRunner, self._runner))
86
88
 
87
- if any(
88
- p.kind == p.VAR_KEYWORD
89
- for p in inspect.signature(self._runner).parameters.values()
90
- ):
89
+ if any(p.kind == p.VAR_KEYWORD for p in inspect.signature(self._runner).parameters.values()):
91
90
  # if command runner accepts **kwargs, pass all parameters
92
91
  coroutine = runner(**parameters)
93
92
  else:
94
93
  # otherwise pass only parameters that are in command runner signature to prevent TypeError: got an unexpected keyword argument
95
- coroutine = runner(
96
- **{
97
- k: v
98
- for k, v in parameters.items()
99
- if k in self._runner.__code__.co_varnames
100
- }
101
- )
94
+ coroutine = runner(**{k: v for k, v in parameters.items() if k in self._runner.__code__.co_varnames})
102
95
 
103
96
  @wraps(runner)
104
97
  async def coroutine_wrapper() -> ResponseOptions:
@@ -139,8 +132,8 @@ class ResponseStatus(Enum):
139
132
 
140
133
 
141
134
  class Response(BaseModel):
142
- voice: str = ""
143
- text: str = ""
135
+ voice: str | LocalizableString = ""
136
+ text: str | LocalizableString = ""
144
137
  status: ResponseStatus = ResponseStatus.success
145
138
  needs_user_input: bool = False
146
139
  commands: list[Command] = []
@@ -15,6 +15,7 @@ from stark.core.commands_context_processor import (
15
15
  )
16
16
  from stark.core.parsing import PatternParser
17
17
  from stark.core.types.object import Object
18
+ from stark.general.localisation import LocaleString, Localizer
18
19
 
19
20
  from ..general.dependencies import DependencyManager, default_dependency_manager
20
21
  from .command import (
@@ -57,11 +58,12 @@ class CommandsContext:
57
58
  commands_manager: CommandsManager,
58
59
  dependency_manager: DependencyManager = default_dependency_manager,
59
60
  processors: list[Any] | None = None,
61
+ localizer: Localizer | None = None,
60
62
  ):
61
63
  assert isinstance(task_group, TaskGroup), task_group
62
64
  assert isinstance(commands_manager, CommandsManager)
63
65
  assert isinstance(dependency_manager, DependencyManager)
64
- self.pattern_parser = PatternParser()
66
+ self.pattern_parser = PatternParser(localizer=localizer)
65
67
  self.commands_manager = commands_manager
66
68
  self.context_queue = [self.root_context]
67
69
  self._response_queue = []
@@ -88,7 +90,9 @@ class CommandsContext:
88
90
  def root_context(self):
89
91
  return CommandsContextLayer(self.commands_manager.commands, {})
90
92
 
91
- async def process_string(self, string: str):
93
+ async def process_string(self, string: str | LocaleString):
94
+ string = LocaleString(string) if not isinstance(string, LocaleString) else string
95
+
92
96
  if not self.context_queue:
93
97
  self.context_queue.append(self.root_context)
94
98
 
@@ -100,7 +104,9 @@ class CommandsContext:
100
104
 
101
105
  for processor in self.processors:
102
106
  logger.debug(f"Processing context {processor=} with {string=} {recognized_entities=} {self.context_queue=}")
103
- search_results, context_pops = await processor.process_string(string, self, recognized_entities)
107
+ search_results, context_pops = await processor.process_string(
108
+ string, self, recognized_entities
109
+ )
104
110
  if search_results:
105
111
  # Pop contexts as directed by processor
106
112
  for _ in range(context_pops):
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
8
8
  from stark.core.command import Command
9
9
  from stark.core.parsing import RecognizedEntity
10
10
  from stark.core.types.object import Object
11
+ from stark.general.localisation import LocaleString
11
12
 
12
13
  from .commands_manager import SearchResult
13
14
 
@@ -42,7 +43,10 @@ class CommandsContextProcessor(ABC):
42
43
  """
43
44
 
44
45
  async def process_string(
45
- self, string: str, context: CommandsContext, recognized_entities: list[RecognizedEntity]
46
+ self,
47
+ string: LocaleString,
48
+ context: CommandsContext,
49
+ recognized_entities: list[RecognizedEntity],
46
50
  ) -> tuple[list[SearchResult], int]:
47
51
  """
48
52
  Processes the entire context queue.
@@ -72,7 +76,7 @@ class CommandsContextProcessor(ABC):
72
76
 
73
77
  async def process_context_layer(
74
78
  self,
75
- string: str,
79
+ string: LocaleString,
76
80
  context: CommandsContext,
77
81
  context_layer: CommandsContextLayer,
78
82
  recognized_entities: list[RecognizedEntity],
@@ -31,9 +31,14 @@ class CommandsManager:
31
31
  return command
32
32
  return None
33
33
 
34
- def new(self, pattern_str: str, hidden: bool = False):
34
+ def new(self, pattern_str: str | dict[str, str], hidden: bool = False):
35
35
  def creator(runner: CommandRunner) -> Command:
36
- pattern = Pattern(pattern_str)
36
+ if isinstance(pattern_str, dict):
37
+ patterns = {lang: Pattern(p) for lang, p in pattern_str.items()}
38
+ if 'base' not in patterns:
39
+ patterns['base'] = next(iter(patterns.values()))
40
+ else:
41
+ patterns = {'base': Pattern(pattern_str)}
37
42
 
38
43
  # take the main type from Optionals
39
44
  annotations = dict()
@@ -60,7 +65,7 @@ class CommandsManager:
60
65
 
61
66
  # create command
62
67
 
63
- cmd = Command(f"{self.name}.{runner.__name__}", pattern, runner)
68
+ cmd = Command(f"{self.name}.{runner.__name__}", patterns, runner)
64
69
 
65
70
  if not hidden:
66
71
  self.commands.append(cmd)
@@ -0,0 +1,70 @@
1
+ import warnings
2
+
3
+ from stark.core.command import Command
4
+ from stark.core.parsing import PatternParser, _LOCALIZER_KEY_REGEX
5
+
6
+
7
+ def health_check(pattern_parser: PatternParser, commands: list[Command]) -> None:
8
+ # TODO: update schema and validation; handle optional parameters; handle short form where type is defined in object
9
+
10
+ # 1. Registered types: pattern parameters and annotations match; all used types are registered
11
+ for reg_type in pattern_parser.parameter_types_by_name.values():
12
+ for lang, type_pattern in reg_type.type.patterns.items():
13
+ pattern_params = set(type_pattern.parameters.keys())
14
+ class_params = set(reg_type.type.__annotations__.keys())
15
+ assert pattern_params <= class_params, (
16
+ f"Pattern parameters {pattern_params} do not match class annotations {class_params} for {reg_type.type.__name__} (language '{lang}')"
17
+ )
18
+ # 2. Registered types: All referenced parameter types are registered
19
+ for param_name in pattern_params:
20
+ assert type_pattern.parameters[param_name].type_name in pattern_parser.parameter_types_by_name, (
21
+ f"Unknown parameter type '{param_name}' in pattern '{type_pattern}' of type '{reg_type.type.__name__}' (language '{lang}'). Known types: {sorted(pattern_parser.parameter_types_by_name.keys())}"
22
+ )
23
+
24
+ # 3. No duplicate parameter types registered
25
+ type_names = list(pattern_parser.parameter_types_by_name.keys())
26
+ if len(type_names) != len(set(type_names)):
27
+ duplicates = {name for name in type_names if type_names.count(name) > 1}
28
+ assert not duplicates, f"Duplicate parameter type names found: {sorted(duplicates)}"
29
+
30
+ # 4. Commands: validate @key references and parameter types
31
+ for command in commands:
32
+ for lang, cmd_pattern in command.patterns.items():
33
+ if pattern_parser.localizer and pattern_parser._has_localizer_keys(cmd_pattern):
34
+ for key in _LOCALIZER_KEY_REGEX.findall(cmd_pattern._origin):
35
+ pattern_parser.localizer.verify_key_exists(key)
36
+ for param in command.get_pattern("base").parameters.values():
37
+ assert param.type_name in pattern_parser.parameter_types_by_name, (
38
+ f"Unknown parameter type '{param.type_name}' in pattern '{command.get_pattern("base")}' of command '{command.name}'. Known types: {sorted(pattern_parser.parameter_types_by_name.keys())}"
39
+ )
40
+
41
+ # 5. Commands: all pattern params are expected by the runner func
42
+ for command in commands:
43
+ pattern_params = set(command.get_pattern("base").parameters.keys())
44
+ runner_params = set(command._runner.__annotations__.keys())
45
+ assert pattern_params <= runner_params, f"Command '{command.name}' function missing parameters: {pattern_params - runner_params}"
46
+
47
+ # 6. Pattern syntax validity (TODO)
48
+ # for command in commands:
49
+ # assert command.get_pattern("base").is_valid(), f"Invalid pattern syntax: {command.get_pattern("base").origin}"
50
+
51
+ # 7. Try compiling all patterns to catch syntax errors early
52
+ for reg_type in pattern_parser.parameter_types_by_name.values():
53
+ for lang, p in reg_type.type.patterns.items():
54
+ try:
55
+ pattern_parser._compile_pattern(p, language_code=lang)
56
+ except Exception as e:
57
+ warnings.warn(f"Failed to compile pattern '{p}' of type '{reg_type.type.__name__}' (language '{lang}'): {e}")
58
+ for command in commands:
59
+ for lang, p in command.patterns.items():
60
+ try:
61
+ pattern_parser._compile_pattern(p, language_code=lang)
62
+ except Exception as e:
63
+ warnings.warn(f"Failed to compile pattern '{p}' of command '{command.name}' (language '{lang}'): {e}")
64
+
65
+ # 8. No unused types
66
+ used_types = {param.type_name for command in commands for param in command.get_pattern("base").parameters.values()}
67
+ for type_name in pattern_parser.parameter_types_by_name:
68
+ # assert type_name in used_types, f"Registered type {type_name} is not used in any pattern"
69
+ if type_name not in used_types:
70
+ warnings.warn(f"Registered type {type_name} is not used in any pattern")
@@ -11,6 +11,7 @@ from stark.core.types import Object
11
11
  from stark.core.types.string import String
12
12
  from stark.core.types.word import Word
13
13
  from stark.general.cache import alru_cache
14
+ from stark.general.localisation import LanguageCode, LocaleString, Localizer
14
15
 
15
16
  type ObjectType = type[Object]
16
17
 
@@ -19,6 +20,8 @@ import logging
19
20
 
20
21
  logger = logging.getLogger(__name__)
21
22
 
23
+ _LOCALIZER_KEY_REGEX = re.compile(r"@(?P<key>[A-Za-z_][A-Za-z0-9_]*)")
24
+
22
25
 
23
26
  class ParseError(Exception):
24
27
  pass
@@ -62,7 +65,13 @@ class RecognizedEntity:
62
65
 
63
66
 
64
67
  class ObjectParser(ABC):
65
- async def did_parse(self, obj: Object, from_string: str) -> str:
68
+ localizer: Localizer | None = None
69
+
70
+ @property
71
+ def patterns(self) -> dict[str, Pattern] | None:
72
+ return None
73
+
74
+ async def did_parse(self, obj: Object, from_string: LocaleString) -> str:
66
75
  """
67
76
  This method is called after parsing from string and setting parameters found in pattern.
68
77
  You will very rarely, if ever, need to call this method directly.
@@ -80,9 +89,11 @@ class ObjectParser(ABC):
80
89
 
81
90
  class PatternParser:
82
91
  parameter_types_by_name: dict[str, RegisteredParameterType] = {}
92
+ localizer: Localizer | None
83
93
 
84
- def __init__(self):
94
+ def __init__(self, localizer: Localizer | None = None):
85
95
  self.parameter_types_by_name = {}
96
+ self.localizer = localizer
86
97
  self.register_parameter_type(Word)
87
98
  self.register_parameter_type(String)
88
99
 
@@ -92,35 +103,70 @@ class PatternParser:
92
103
  parser: ObjectParser | None = None,
93
104
  # TODO: consider adding lang parameter
94
105
  ):
95
- assert issubclass(object_type, Object), f'Can`t add parameter type "{object_type.__name__}": it is not a subclass of Object'
106
+ assert issubclass(object_type, Object), (
107
+ f'Can`t add parameter type "{object_type.__name__}": it is not a subclass of Object'
108
+ )
96
109
  exists_type = self.parameter_types_by_name.get(object_type.__name__)
97
- assert exists_type is None, f"Duplicate parameter type: can`t add parameter type '{object_type.__name__}' because it already exists"
110
+ assert exists_type is None, (
111
+ f"Duplicate parameter type: can`t add parameter type '{object_type.__name__}' because it already exists"
112
+ )
113
+ resolved_parser = parser or ObjectParser()
114
+ resolved_parser.localizer = self.localizer
98
115
  self.parameter_types_by_name[object_type.__name__] = RegisteredParameterType(
99
- name=object_type.__name__, type=object_type, parser=parser or ObjectParser()
116
+ name=object_type.__name__, type=object_type, parser=resolved_parser
100
117
  )
118
+ self._validate_localizer_keys(object_type)
119
+
120
+ def _validate_localizer_keys(self, object_type: ObjectType):
121
+ for pattern in object_type.patterns.values():
122
+ keys = _LOCALIZER_KEY_REGEX.findall(pattern._origin)
123
+ if not keys:
124
+ continue
125
+ if not self.localizer:
126
+ raise ValueError(
127
+ f"Pattern '{pattern._origin}' of type '{object_type.__name__}' contains @key references but no Localizer is configured on PatternParser"
128
+ )
129
+ for key in keys:
130
+ self.localizer.verify_key_exists(key)
131
+
132
+ @staticmethod
133
+ def _has_localizer_keys(pattern: Pattern) -> bool:
134
+ return bool(_LOCALIZER_KEY_REGEX.search(pattern._origin))
101
135
 
102
- async def parse_object_name(self, class_name: str, from_string: str) -> ParseResult:
136
+ async def parse_object_name(self, class_name: str, from_string: str | LocaleString) -> ParseResult:
103
137
  return await self.parse_object(self.parameter_types_by_name[class_name].type, from_string)
104
138
 
105
- async def parse_object(self, object_type: ObjectType, from_string: str) -> ParseResult:
139
+ async def parse_object(self, object_type: ObjectType, from_string: str | LocaleString) -> ParseResult:
106
140
  async for obj in self.parse_objects(object_type, from_string):
107
141
  return obj # take the first successful parsed result, usually the only one
108
142
  else:
109
143
  raise ParseError(f"Failed to parse object of type {object_type.__name__} from string '{from_string}'")
110
144
 
111
- async def parse_objects(self, object_type: ObjectType, from_string: str) -> AsyncGenerator[ParseResult]:
112
- object_matches = await self.match(object_type.pattern, from_string)
145
+ def _resolve_pattern(self, object_type: ObjectType, language_code: LanguageCode) -> Pattern:
146
+ parser = self.parameter_types_by_name[object_type.__name__].parser
147
+ patterns = parser.patterns or object_type.patterns
148
+ if language_code in patterns:
149
+ return patterns[language_code]
150
+ return patterns["base"]
151
+
152
+ async def parse_objects(
153
+ self, object_type: ObjectType, from_string: str | LocaleString
154
+ ) -> AsyncGenerator[ParseResult]:
155
+ from_string = from_string if isinstance(from_string, LocaleString) else LocaleString(from_string)
156
+ language_code = from_string.language_code
157
+ pattern = self._resolve_pattern(object_type, language_code)
158
+ object_matches = await self.match(pattern, from_string)
113
159
 
114
160
  for object_pattern_match in object_matches:
115
- string = object_pattern_match.substring
161
+ string = from_string._with(object_pattern_match.substring) # TODO: review _with usage
116
162
 
117
163
  parser: ObjectParser = self.parameter_types_by_name[object_type.__name__].parser
118
164
  obj = object_type(string) # temp/default value, may be overridden by did_parse
119
165
 
120
166
  matched_subparams = object_pattern_match.parameters
121
- assert matched_subparams.keys() <= {p.name for p in object_type.pattern.parameters.values() if not p.optional}
167
+ assert matched_subparams.keys() <= {p.name for p in pattern.parameters.values() if not p.optional}
122
168
 
123
- for name in object_type.pattern.parameters:
169
+ for name in pattern.parameters:
124
170
  value = matched_subparams[name]
125
171
  setattr(obj, name, value)
126
172
 
@@ -141,14 +187,22 @@ class PatternParser:
141
187
  yield ParseResult(obj, substring)
142
188
 
143
189
  @alru_cache(maxsize=256, ttl=60 * 10) # TODO: env vars + disable option
144
- async def _did_parse(self, obj: Object, parser: ObjectParser, string: str) -> str:
190
+ async def _did_parse(self, obj: Object, parser: ObjectParser, string: LocaleString) -> str:
145
191
  substring = await parser.did_parse(obj, string)
192
+ substring = string._with(substring) if not isinstance(substring, LocaleString) else substring
146
193
  substring = await obj.did_parse(substring)
147
194
  return substring
148
195
 
149
- async def match(self, pattern: Pattern, string: str, recognized_entities: list[RecognizedEntity] | None = None) -> list[MatchResult]:
196
+ async def match(
197
+ self,
198
+ pattern: Pattern,
199
+ string: str | LocaleString,
200
+ recognized_entities: list[RecognizedEntity] | None = None,
201
+ ) -> list[MatchResult]:
202
+ string = string if isinstance(string, LocaleString) else LocaleString(string)
203
+ language_code = string.language_code
150
204
  recognized_entities = recognized_entities or []
151
- compiled = self._compile_pattern(pattern)
205
+ compiled = self._compile_pattern(pattern, language_code=language_code)
152
206
  logger.debug(f'Starting looking for "{pattern=}" "{compiled=}" in "{string}"')
153
207
 
154
208
  matches = []
@@ -159,7 +213,9 @@ class PatternParser:
159
213
 
160
214
  new_match = None
161
215
  command_substr = string[match.start() : match.end()]
162
- new_match, parsed_parameters = await self._parse_parameters_for_match(pattern, command_substr, recognized_entities)
216
+ new_match, parsed_parameters = await self._parse_parameters_for_match(
217
+ pattern, command_substr, recognized_entities, language_code
218
+ )
163
219
  assert new_match is not None, "new_match should not be None"
164
220
  new_match = new_match or match
165
221
 
@@ -186,7 +242,10 @@ class PatternParser:
186
242
  substring=command_str,
187
243
  start=start,
188
244
  end=end,
189
- parameters={name: (parameter.parsed_obj if parameter else None) for name, parameter in all_parameters.items()},
245
+ parameters={
246
+ name: (parameter.parsed_obj if parameter else None)
247
+ for name, parameter in all_parameters.items()
248
+ },
190
249
  )
191
250
  )
192
251
  logger.debug(f"Match result: {matches[-1]}")
@@ -198,7 +257,11 @@ class PatternParser:
198
257
  return sorted(re.finditer(compiled, string), key=lambda match: match.start())
199
258
 
200
259
  async def _parse_parameters_for_match(
201
- self, pattern: Pattern, string: str, recognized_entities: list[RecognizedEntity]
260
+ self,
261
+ pattern: Pattern,
262
+ string: LocaleString,
263
+ recognized_entities: list[RecognizedEntity],
264
+ language_code: LanguageCode,
202
265
  ) -> tuple[re.Match | None, dict[str, ParameterMatch]]:
203
266
  parsed_parameters: dict[str, ParameterMatch] = {}
204
267
  logger.debug(f'Captured candidate "{string}"')
@@ -214,7 +277,7 @@ class PatternParser:
214
277
  prefill = {name: parameter.parsed_substr for name, parameter in parsed_parameters.items()}
215
278
 
216
279
  # re-run regex only in the current command_str
217
- compiled = self._compile_pattern(pattern, prefill=prefill)
280
+ compiled = self._compile_pattern(pattern, prefill=prefill, language_code=language_code)
218
281
  new_matches = list(re.finditer(compiled, string))
219
282
 
220
283
  logger.debug(f'Re capturing parameters {string} prefill={prefill} compiled="{compiled}"')
@@ -269,7 +332,10 @@ class PatternParser:
269
332
  ),
270
333
  )
271
334
  parameter_match = await self._parse_single_parameter(
272
- pattern, parameter_name, match_str_groups[parameter_name].strip(), recognized_entities
335
+ pattern,
336
+ parameter_name,
337
+ string._with(match_str_groups[parameter_name].strip()),
338
+ recognized_entities,
273
339
  )
274
340
  if parameter_match is not None:
275
341
  parsed_parameters[parameter_name] = parameter_match
@@ -277,7 +343,11 @@ class PatternParser:
277
343
  return new_match, parsed_parameters
278
344
 
279
345
  async def _parse_single_parameter(
280
- self, pattern: Pattern, parameter_name: str, raw_param_substr: str, recognized_entities: list[RecognizedEntity]
346
+ self,
347
+ pattern: Pattern,
348
+ parameter_name: str,
349
+ raw_param_substr: LocaleString,
350
+ recognized_entities: list[RecognizedEntity],
281
351
  ) -> ParameterMatch | None:
282
352
  param = pattern.group_name_to_param[parameter_name]
283
353
  parameter_reg_type = self.parameter_types_by_name[param.type_name]
@@ -287,7 +357,7 @@ class PatternParser:
287
357
  # check recognized entities, update substring if found match
288
358
  for entity in recognized_entities:
289
359
  if entity.substring in raw_param_substr and entity.type is parameter_reg_type.type:
290
- raw_param_substr = entity.substring
360
+ raw_param_substr = raw_param_substr._with(entity.substring)
291
361
  logger.debug(f"Adjusting for recognized entity: '{entity.substring}'")
292
362
 
293
363
  # parse the object
@@ -321,11 +391,27 @@ class PatternParser:
321
391
  pattern: Pattern,
322
392
  group_prefix: str = "",
323
393
  prefill: dict[str, str] | None = None,
394
+ language_code: LanguageCode = "base",
324
395
  ) -> str: # transform Pattern to classic regex with named groups
325
396
  prefill = prefill or {}
326
397
 
327
398
  pattern_str: str = pattern._origin
328
399
 
400
+ if self._has_localizer_keys(pattern):
401
+ if not self.localizer:
402
+ raise ValueError(
403
+ f"Pattern '{pattern._origin}' contains @key references but no Localizer is configured on PatternParser"
404
+ )
405
+
406
+ def _resolve_key(m: re.Match) -> str:
407
+ key = m.group("key")
408
+ resolved = self.localizer.get_recognizable(key, language_code)
409
+ if resolved is None:
410
+ raise KeyError(f"Localizer key '@{key}' not found for language '{language_code}'")
411
+ return resolved
412
+
413
+ pattern_str = _LOCALIZER_KEY_REGEX.sub(_resolve_key, pattern_str)
414
+
329
415
  # transform core expressions to regex
330
416
 
331
417
  for rule in rules_list:
@@ -341,17 +427,24 @@ class PatternParser:
341
427
  for parameter in pattern.parameters.values():
342
428
  parameter_type = self.parameter_types_by_name[parameter.type_name].type
343
429
 
344
- arg_declaration = f"\\${parameter.name}\\:{parameter_type.__name__}" # NOTE: special chars escaped for regex
430
+ arg_declaration = (
431
+ f"\\${parameter.name}\\:{parameter_type.__name__}" # NOTE: special chars escaped for regex
432
+ )
345
433
  if parameter.name in prefill:
346
434
  arg_pattern = prefill[parameter.name] # TODO: review wether re.escape is needed
347
435
  else: # replace pattern annotation with compiled regex
348
436
  parameter.group_name = ((group_prefix + "_") if group_prefix else "") + parameter.name
349
- arg_pattern = self._compile_pattern(parameter_type.pattern, group_prefix=parameter.group_name).replace("\\", r"\\")
437
+ param_pattern = self._resolve_pattern(parameter_type, language_code)
438
+ arg_pattern = self._compile_pattern(
439
+ param_pattern, group_prefix=parameter.group_name, language_code=language_code
440
+ ).replace("\\", r"\\")
350
441
 
351
442
  if parameter_type.greedy and arg_pattern[-1] in {"*", "+", "}", "?"}:
352
443
  # compensate greedy did_parse with non-greedy regex, so it won't consume next params during initial regex
353
444
  arg_pattern += "?"
354
- if pattern_str.endswith(f"${parameter.name}:{parameter_type.__name__}"): # NOTE: regex chars are NOT escaped
445
+ if pattern_str.endswith(
446
+ f"${parameter.name}:{parameter_type.__name__}"
447
+ ): # NOTE: regex chars are NOT escaped
355
448
  # compensate the last non_greedy regex to allow consuming till the end of the string
356
449
  # middle greedy params are limited/stretched by neighbor params
357
450
  arg_pattern += "$"
@@ -27,7 +27,6 @@ class PatternParameter:
27
27
 
28
28
  class Pattern:
29
29
  parameters: dict[str, PatternParameter]
30
- # compiled: str
31
30
 
32
31
  _origin: str
33
32
  _parameter_regex: re.Pattern
@@ -5,6 +5,7 @@ from typing import override
5
5
  from asyncer import SoonValue, create_task_group
6
6
 
7
7
  from stark.core.parsing import MatchResult, PatternParser, RecognizedEntity
8
+ from stark.general.localisation import LocaleString
8
9
 
9
10
  from ..command import Command
10
11
  from ..commands_context import CommandsContext, CommandsContextLayer
@@ -15,11 +16,13 @@ from ..commands_manager import SearchResult
15
16
  class SearchProcessor(CommandsContextProcessor):
16
17
  async def search(
17
18
  self,
18
- string: str,
19
+ string: str | LocaleString,
19
20
  pattern_parser: PatternParser,
20
21
  commands: list[Command],
21
22
  recognized_entities: list[RecognizedEntity],
22
23
  ) -> list[SearchResult]:
24
+ string = string if isinstance(string, LocaleString) else LocaleString(string)
25
+ language_code = string.language_code
23
26
  results: list[SearchResult] = []
24
27
  futures: list[tuple[Command, SoonValue[list[MatchResult]]]] = []
25
28
 
@@ -29,7 +32,9 @@ class SearchProcessor(CommandsContextProcessor):
29
32
  futures.append(
30
33
  (
31
34
  command,
32
- group.soonify(pattern_parser.match)(command.pattern, string, recognized_entities),
35
+ group.soonify(pattern_parser.match)(
36
+ command.get_pattern(language_code), string, recognized_entities
37
+ ),
33
38
  )
34
39
  )
35
40
 
@@ -46,19 +51,28 @@ class SearchProcessor(CommandsContextProcessor):
46
51
 
47
52
  # copy to prevent affecting iteration by removing items; slice makes copy automatically
48
53
  for prev, current in zip(results.copy(), results[1:]): # TODO: concurrent; optimize using cache
49
- if prev.match_result.start == current.match_result.start or prev.match_result.end > current.match_result.start:
54
+ if (
55
+ prev.match_result.start == current.match_result.start
56
+ or prev.match_result.end > current.match_result.start
57
+ ):
50
58
  # constrain prev end to current start
51
59
  prev_cut = await pattern_parser.match(
52
- prev.command.pattern, string[prev.match_result.start : current.match_result.start], recognized_entities
60
+ prev.command.get_pattern(language_code),
61
+ string[prev.match_result.start : current.match_result.start],
62
+ recognized_entities,
53
63
  )
54
64
  # constrain current start to prev end
55
65
  current_cut = await pattern_parser.match(
56
- current.command.pattern, string[prev.match_result.end : current.match_result.end], recognized_entities
66
+ current.command.get_pattern(language_code),
67
+ string[prev.match_result.end : current.match_result.end],
68
+ recognized_entities,
57
69
  )
58
70
 
59
71
  # less index = more priority to save full match
60
72
  priority1, priority2 = (prev, current) if prev.index < current.index else (current, prev)
61
- priority1_cut, priority2_cut = (prev_cut, current_cut) if prev.index < current.index else (current_cut, prev_cut)
73
+ priority1_cut, priority2_cut = (
74
+ (prev_cut, current_cut) if prev.index < current.index else (current_cut, prev_cut)
75
+ )
62
76
 
63
77
  if new_matches := priority2_cut: # if can cut less priority
64
78
  priority2.match_result = new_matches[0]
@@ -74,7 +88,7 @@ class SearchProcessor(CommandsContextProcessor):
74
88
  @override
75
89
  async def process_context_layer(
76
90
  self,
77
- string: str,
91
+ string: LocaleString,
78
92
  context: CommandsContext,
79
93
  context_layer: CommandsContextLayer,
80
94
  recognized_entities: list[RecognizedEntity],