robotcode-robot 2.7.0__py3-none-any.whl → 2.8.0.dev548__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 (32) hide show
  1. robotcode/robot/__version__.py +1 -1
  2. robotcode/robot/config/loader.py +18 -5
  3. robotcode/robot/config/model.py +229 -61
  4. robotcode/robot/diagnostics/analyzer_result.py +1 -0
  5. robotcode/robot/diagnostics/data_cache.py +12 -1
  6. robotcode/robot/diagnostics/diagnostic_rules.py +75 -0
  7. robotcode/robot/diagnostics/diagnostics_modifier.py +83 -43
  8. robotcode/robot/diagnostics/document_cache_helper.py +76 -8
  9. robotcode/robot/diagnostics/errors.py +7 -0
  10. robotcode/robot/diagnostics/import_resolver.py +162 -12
  11. robotcode/robot/diagnostics/imports_manager.py +318 -97
  12. robotcode/robot/diagnostics/library_doc.py +874 -162
  13. robotcode/robot/diagnostics/namespace.py +60 -15
  14. robotcode/robot/diagnostics/namespace_analyzer.py +59 -16
  15. robotcode/robot/diagnostics/project_index.py +88 -13
  16. robotcode/robot/diagnostics/scope_tree.py +2 -2
  17. robotcode/robot/diagnostics/semantic_analyzer/analyzer.py +790 -201
  18. robotcode/robot/diagnostics/semantic_analyzer/enums.py +39 -2
  19. robotcode/robot/diagnostics/semantic_analyzer/json_dump.py +364 -0
  20. robotcode/robot/diagnostics/semantic_analyzer/model.py +72 -11
  21. robotcode/robot/diagnostics/semantic_analyzer/nodes.py +7 -1
  22. robotcode/robot/diagnostics/semantic_analyzer/variable_tokenizer.py +218 -112
  23. robotcode/robot/diagnostics/workspace_config.py +3 -0
  24. robotcode/robot/utils/markdown_docs.py +733 -0
  25. robotcode/robot/utils/markdownformatter.py +57 -12
  26. robotcode/robot/utils/match.py +13 -0
  27. robotcode/robot/utils/robot_patching.py +4 -0
  28. robotcode/robot/utils/variables.py +3 -0
  29. {robotcode_robot-2.7.0.dist-info → robotcode_robot-2.8.0.dev548.dist-info}/METADATA +4 -3
  30. robotcode_robot-2.8.0.dev548.dist-info/RECORD +48 -0
  31. {robotcode_robot-2.7.0.dist-info → robotcode_robot-2.8.0.dev548.dist-info}/WHEEL +1 -1
  32. robotcode_robot-2.7.0.dist-info/RECORD +0 -46
@@ -1,6 +1,14 @@
1
1
  """Shared diagnostic rule helpers."""
2
2
 
3
+ import re
4
+ from itertools import groupby
5
+ from typing import List, Optional
6
+
7
+ from robot.parsing.lexer.tokens import Token
8
+ from robot.parsing.model.statements import Statement
9
+
3
10
  from .entities import VariableDefinition
11
+ from .library_doc import KeywordDoc
4
12
 
5
13
 
6
14
  def is_variable_name_intentionally_unused(variable: VariableDefinition) -> bool:
@@ -10,3 +18,70 @@ def is_variable_name_intentionally_unused(variable: VariableDefinition) -> bool:
10
18
  and bool(variable.name_token.value)
11
19
  and variable.name_token.value.startswith("_")
12
20
  )
21
+
22
+
23
+ def private_keyword_call_message(keyword: KeywordDoc, source: Optional[str]) -> Optional[str]:
24
+ """The `PrivateKeyword` message for a call of `keyword` in the file `source`, or `None` if the call is fine.
25
+
26
+ The file that contains the call decides, not the keyword that calls: Robot Framework also warns when
27
+ a test case calls a private keyword of its own suite file, which is robotframework/robotframework#5807.
28
+ """
29
+ if not keyword.is_private or keyword.source == source:
30
+ return None
31
+ if keyword.is_resource_keyword:
32
+ return f"Keyword '{keyword.longname}' is private and should only be called by keywords in the same file."
33
+ if keyword.is_library_keyword:
34
+ return f"Keyword '{keyword.longname}' is private and should not be called from Robot Framework files."
35
+ return None
36
+
37
+
38
+ # The section headers of keyword documentation, as Robot Framework 7.5 recognizes them.
39
+ _DOC_SECTION_HEADER = re.compile(
40
+ r"[*_]*(args|arguments|parameters|returns|return|yields|raises|raise|tags)[*_]*::?[*_]*(\s+.*)?",
41
+ re.IGNORECASE,
42
+ )
43
+ _TRAILING_BACKSLASH_OR_NEWLINE = re.compile(r"(\\+)n?$")
44
+
45
+
46
+ def tags_line_without_empty_row(doc: str) -> Optional[int]:
47
+ """Index of the first `Tags:` line Robot Framework 7.5 warns about, or `None`.
48
+
49
+ Robot Framework accepts a section header only where a section may start:
50
+ on the first line, after an empty line and inside a named section
51
+ (`Args:`, `Returns:`, ...), whose indented and empty lines belong to it.
52
+ A `Tags:` header anywhere else is still used but deprecated.
53
+ """
54
+ in_named_section = False
55
+ can_start = True
56
+ for index, line in enumerate(doc.splitlines()):
57
+ header = _DOC_SECTION_HEADER.fullmatch(line)
58
+ if header and can_start:
59
+ in_named_section = True
60
+ elif header and header.group(1).lower() == "tags":
61
+ return index
62
+ elif in_named_section and line and not line[:2].isspace():
63
+ in_named_section = False
64
+ can_start = in_named_section or not line.strip()
65
+ return None
66
+
67
+
68
+ def tags_row_without_empty_row(documentation: Statement) -> Optional[List[Token]]:
69
+ """The tokens of the `[Documentation]` row with a `Tags:` header Robot Framework warns about, or `None`."""
70
+ index = tags_line_without_empty_row(documentation.value)
71
+ if index is None:
72
+ return None
73
+
74
+ # `value` has one line per row, except that a row ending with a backslash or `\n` continues on the next row
75
+ line = 0
76
+ for _, row_tokens in groupby(documentation.get_tokens(Token.ARGUMENT), key=lambda t: t.lineno):
77
+ row = list(row_tokens)
78
+ if line == index:
79
+ return row
80
+ if not _has_trailing_backslash_or_newline(row[-1].value):
81
+ line += 1
82
+ return None
83
+
84
+
85
+ def _has_trailing_backslash_or_newline(value: str) -> bool:
86
+ match = _TRAILING_BACKSLASH_OR_NEWLINE.search(value)
87
+ return bool(match and len(match.group(1)) % 2 == 1)
@@ -4,7 +4,7 @@ from ast import AST
4
4
  from collections import defaultdict
5
5
  from dataclasses import dataclass, field
6
6
  from enum import Enum
7
- from typing import Dict, List, Optional, Set, Union
7
+ from typing import Dict, List, Optional, Set, Tuple, Union
8
8
 
9
9
  from robot.parsing.lexer.tokens import Token
10
10
  from robot.parsing.model.blocks import Block, File
@@ -13,7 +13,8 @@ from robotcode.core.lsp.types import Diagnostic, DiagnosticSeverity
13
13
 
14
14
  from ..utils.visitor import Visitor
15
15
 
16
- ACTIONS = ["ignore", "error", "warn", "warning", "info", "information", "hint", "reset"]
16
+ # The long names come before their short forms, the pattern takes the first alternative that matches.
17
+ ACTIONS = ["ignore", "error", "warning", "warn", "information", "info", "hint", "reset"]
17
18
 
18
19
  ROBOTCODE_ACTION_AND_CODES_PATTERN = re.compile(rf"(?P<action>{'|'.join(ACTIONS)})(\[(?P<codes>[^\]]*?)\])?")
19
20
 
@@ -35,10 +36,22 @@ class ModifierAction(Enum):
35
36
  return cls(value)
36
37
 
37
38
 
39
+ # How near a modifier is to the line it affects: an inline modifier at the end of the line, an indented comment in
40
+ # a block, or a top-level comment of the file.
41
+ _FILE_LEVEL = 0
42
+ _BLOCK_LEVEL = 1
43
+ _INLINE_LEVEL = 2
44
+
45
+ # The rank of a modifier: its level, then the line of its comment, then its position in the comment. The higher rank
46
+ # wins, also between a modifier for a code and one without a list (`*`).
47
+ Rank = Tuple[int, int, int]
48
+
49
+
38
50
  @dataclass
39
51
  class RulesAndCodes:
40
52
  codes: Dict[Union[str, int], Set[int]]
41
53
  actions: Dict[int, Dict[Union[str, int], ModifierAction]]
54
+ ranks: Dict[int, Dict[Union[str, int], Rank]] = field(default_factory=lambda: defaultdict(dict))
42
55
 
43
56
 
44
57
  _translation_table = str.maketrans("", "", "_- ")
@@ -79,15 +92,19 @@ class ModifiersVisitor(Visitor):
79
92
  self.generic_visit(node)
80
93
 
81
94
  def visit_Block(self, node: Block) -> None: # noqa: N802
95
+ # Restored afterwards, so that a modifier after a nested block (IF, FOR, ...) belongs to the outer block.
96
+ parent_block = self.current_block
82
97
  self.current_block = node
83
98
  self.generic_visit(node)
99
+ self.current_block = parent_block
84
100
 
85
101
  def visit_Statement(self, node: Statement) -> None: # noqa: N802
86
102
  self._handle_statement_comments(node)
87
103
  self.generic_visit(node)
88
104
 
89
- def _parse_robotcode_disabler(self, comment: str) -> Dict[ModifierAction, List[str]]:
90
- result: Dict[ModifierAction, List[str]] = {}
105
+ def _parse_actions(self, comment: str) -> List[Tuple[ModifierAction, str]]:
106
+ """The actions of a modifier comment and their codes, in the order they are written."""
107
+ result: List[Tuple[ModifierAction, str]] = []
91
108
 
92
109
  comment = comment.strip()
93
110
  m = ROBOTCODE_ACTION_AND_CODES_PATTERN.match(comment)
@@ -98,17 +115,35 @@ class ModifiersVisitor(Visitor):
98
115
  action_str = m.group("action")
99
116
  action = ModifierAction.from_str(action_str)
100
117
  messages = m.group("codes")
101
- result[action] = (
118
+ codes = (
102
119
  [m.strip().translate(_translation_table).lower() for m in messages.split(",")]
103
120
  if messages is not None
104
121
  else ["*"]
105
122
  )
123
+ for code in codes:
124
+ # A code belongs to the rightmost action that names it, also when an action appears more than once.
125
+ result = [entry for entry in result if entry[1] != code]
126
+ result.append((action, code))
106
127
 
107
128
  return result
108
129
 
130
+ def _parse_robotcode_disabler(self, comment: str) -> Dict[ModifierAction, List[str]]:
131
+ result: Dict[ModifierAction, List[str]] = {}
132
+ for action, code in self._parse_actions(comment):
133
+ result.setdefault(action, []).append(code)
134
+ return result
135
+
136
+ def _add_rule(self, lineno: int, code: str, action: ModifierAction, rank: Rank) -> None:
137
+ self.rules_and_codes.codes[code].add(lineno)
138
+ current = self.rules_and_codes.ranks[lineno].get(code)
139
+ if current is None or rank >= current:
140
+ self.rules_and_codes.actions[lineno][code] = action
141
+ self.rules_and_codes.ranks[lineno][code] = rank
142
+
109
143
  def _handle_statement_comments(self, node: Statement) -> None:
110
144
  first_comment = True
111
145
  has_marker = False
146
+ position = 0
112
147
 
113
148
  for token in node.get_tokens(Token.COMMENT):
114
149
  value = token.value.strip()
@@ -127,15 +162,15 @@ class ModifiersVisitor(Visitor):
127
162
  continue
128
163
 
129
164
  if has_marker:
130
- actions = self._parse_robotcode_disabler(value)
165
+ actions = self._parse_actions(value)
131
166
 
132
167
  if not actions:
133
168
  break
134
169
 
135
- for action, codes in actions.items():
136
- for code in codes:
137
- self.rules_and_codes.codes[code].add(token.lineno - 1)
138
- self.rules_and_codes.actions[token.lineno - 1][code] = action
170
+ lineno = token.lineno - 1
171
+ for action, code in actions:
172
+ self._add_rule(lineno, code, action, (_INLINE_LEVEL, lineno, position))
173
+ position += 1
139
174
 
140
175
  def _handle_comment(self, node: Comment) -> None:
141
176
  first_comment = True
@@ -143,9 +178,12 @@ class ModifiersVisitor(Visitor):
143
178
 
144
179
  start_lineno = node.lineno - 1
145
180
  end_lineno = self.file_end_lineno
181
+ level = _FILE_LEVEL
182
+ position = 0
146
183
 
147
184
  if node.tokens[0].type == Token.SEPARATOR and self.current_block is not None:
148
185
  end_lineno = self.current_block.end_lineno - 1
186
+ level = _BLOCK_LEVEL
149
187
  for token in node.get_tokens(Token.COMMENT):
150
188
  value = token.value.strip()
151
189
  if first_comment and value.startswith("#"):
@@ -163,16 +201,16 @@ class ModifiersVisitor(Visitor):
163
201
  continue
164
202
 
165
203
  if has_marker:
166
- actions = self._parse_robotcode_disabler(value)
204
+ actions = self._parse_actions(value)
167
205
 
168
206
  if not actions:
169
207
  break
170
208
 
171
- for action, codes in actions.items():
172
- for code in codes:
173
- self.rules_and_codes.codes[code].update(range(start_lineno, end_lineno + 1))
174
- for i in range(start_lineno, end_lineno + 1):
175
- self.rules_and_codes.actions[i][code] = action
209
+ for action, code in actions:
210
+ rank = (level, start_lineno, position)
211
+ position += 1
212
+ for i in range(start_lineno, end_lineno + 1):
213
+ self._add_rule(i, code, action, rank)
176
214
 
177
215
 
178
216
  @dataclass
@@ -211,34 +249,25 @@ class DiagnosticsModifier:
211
249
  else "unknowncode"
212
250
  )
213
251
 
214
- lines = self.rules_and_codes.codes.get(code)
215
-
216
- if lines is None or (lines is not None and diagnostic.range.start.line not in lines):
217
- code = "*"
218
- lines = self.rules_and_codes.codes.get(code)
219
-
220
252
  modified = False
221
- if lines is not None and diagnostic.range.start.line in lines:
222
- actions = self.rules_and_codes.actions.get(diagnostic.range.start.line)
223
- if actions is not None:
224
- action = actions.get(code)
225
- if action is not None:
226
- if action == ModifierAction.IGNORE:
227
- return None
228
- if action == ModifierAction.RESET:
229
- pass
230
- elif action == ModifierAction.ERROR:
231
- modified = True
232
- diagnostic.severity = DiagnosticSeverity.ERROR
233
- elif action == ModifierAction.WARNING:
234
- modified = True
235
- diagnostic.severity = DiagnosticSeverity.WARNING
236
- elif action == ModifierAction.INFORMATION:
237
- modified = True
238
- diagnostic.severity = DiagnosticSeverity.INFORMATION
239
- elif action == ModifierAction.HINT:
240
- modified = True
241
- diagnostic.severity = DiagnosticSeverity.HINT
253
+ action = self._action_for(diagnostic.range.start.line, code)
254
+ if action is not None:
255
+ if action == ModifierAction.IGNORE:
256
+ return None
257
+ if action == ModifierAction.RESET:
258
+ pass
259
+ elif action == ModifierAction.ERROR:
260
+ modified = True
261
+ diagnostic.severity = DiagnosticSeverity.ERROR
262
+ elif action == ModifierAction.WARNING:
263
+ modified = True
264
+ diagnostic.severity = DiagnosticSeverity.WARNING
265
+ elif action == ModifierAction.INFORMATION:
266
+ modified = True
267
+ diagnostic.severity = DiagnosticSeverity.INFORMATION
268
+ elif action == ModifierAction.HINT:
269
+ modified = True
270
+ diagnostic.severity = DiagnosticSeverity.HINT
242
271
 
243
272
  if not modified:
244
273
  if orig_code in self.config.ignore:
@@ -264,5 +293,16 @@ class DiagnosticsModifier:
264
293
 
265
294
  return diagnostic
266
295
 
296
+ def _action_for(self, line: int, code: str) -> Optional[ModifierAction]:
297
+ """The action of the nearest modifier on `line` for `code`: one that names the code, or one without a list."""
298
+ actions = self.rules_and_codes.actions.get(line)
299
+ if not actions:
300
+ return None
301
+ ranks = self.rules_and_codes.ranks.get(line, {})
302
+ keys = [key for key in (code, "*") if key in actions]
303
+ if not keys:
304
+ return None
305
+ return actions[max(keys, key=lambda key: ranks.get(key, (-1, -1, -1)))]
306
+
267
307
  def modify_diagnostics(self, diagnostics: List[Diagnostic]) -> List[Diagnostic]:
268
308
  return [d for d in map(self.modify_diagnostic, diagnostics) if d is not None]
@@ -8,6 +8,7 @@ from logging import CRITICAL
8
8
  from pathlib import Path
9
9
  from typing import (
10
10
  Any,
11
+ Dict,
11
12
  Iterable,
12
13
  Iterator,
13
14
  List,
@@ -24,7 +25,7 @@ from robotcode.core.filewatcher import FileWatcherManagerBase
24
25
  from robotcode.core.text_document import TextDocument
25
26
  from robotcode.core.uri import Uri
26
27
  from robotcode.core.utils.logging import LoggingDescriptor
27
- from robotcode.core.utils.path import DiskInfo
28
+ from robotcode.core.utils.path import DiskInfo, normalized_path
28
29
  from robotcode.core.workspace import Workspace, WorkspaceFolder
29
30
  from robotcode.robot.diagnostics.diagnostics_modifier import (
30
31
  DiagnosticModifiersConfig,
@@ -66,11 +67,16 @@ def _namespace_cache_key(source: str, document_type: Optional[DocumentType]) ->
66
67
  a suite (GENERAL) and as an imported resource (RESOURCE) — the results
67
68
  must not overwrite each other. "\\n" as separator is impossible in Windows
68
69
  paths and never appears in real-world POSIX paths (where it is technically
69
- legal); the key is an opaque entry name and is never parsed back.
70
+ legal); the key is parsed back only to tell the source of an entry.
70
71
  """
71
72
  return f"{source}\n{document_type.value if document_type is not None else ''}"
72
73
 
73
74
 
75
+ def _namespace_cache_source(entry_name: str) -> str:
76
+ """The source of a namespace entry in the disk cache, see `_namespace_cache_key`."""
77
+ return entry_name.split("\n", 1)[0]
78
+
79
+
74
80
  class _CacheEntry:
75
81
  pass
76
82
 
@@ -121,15 +127,30 @@ class DocumentsCacheHelper:
121
127
 
122
128
  with self._project_indexes_lock:
123
129
  if folder not in self._project_indexes:
124
- self._project_indexes[folder] = ProjectIndex()
130
+ self._project_indexes[folder] = self._create_project_index()
125
131
  return self._project_indexes[folder]
126
132
 
127
133
  def default_project_index(self) -> ProjectIndex:
128
134
  with self._project_indexes_lock:
129
135
  if self._default_project_index is None:
130
- self._default_project_index = ProjectIndex()
136
+ self._default_project_index = self._create_project_index()
131
137
  return self._default_project_index
132
138
 
139
+ def _create_project_index(self) -> ProjectIndex:
140
+ documents_manager = self.documents_manager
141
+ # converting a path is most of the cost of a lookup, and the URI of a file does not change
142
+ uris: Dict[str, Uri] = {}
143
+
144
+ # only the files the documents manager still holds count; the check refers to the documents manager and
145
+ # not to this helper, which would make the helper and its indexes a reference cycle
146
+ def is_known_source(source: str) -> bool:
147
+ uri = uris.get(source)
148
+ if uri is None:
149
+ uri = uris[source] = Uri.from_path(source).normalized()
150
+ return documents_manager.get(uri) is not None
151
+
152
+ return ProjectIndex(is_known_source)
153
+
133
154
  def get_languages_for_document(self, document_or_uri: Union[TextDocument, Uri, str]) -> Optional[Languages]:
134
155
  if RF_VERSION < (6, 0):
135
156
  return None
@@ -418,9 +439,7 @@ class DocumentsCacheHelper:
418
439
  def namespace_invalidated(sender, namespace: Namespace) -> None: ...
419
440
 
420
441
  def _invalidate_namespace(self, sender: Namespace) -> None:
421
- if sender.document is not None:
422
- self.get_project_index(sender.document).remove_file(sender.source)
423
-
442
+ # the file's references stay in the index until its next analysis replaces them
424
443
  document = sender.document
425
444
  if document is not None:
426
445
  document.remove_cache_entry(self.__get_general_namespace)
@@ -634,6 +653,53 @@ class DocumentsCacheHelper:
634
653
 
635
654
  return result
636
655
 
656
+ def remove_cached_analysis(self, path: Path) -> None:
657
+ """Remove the entries of a Robot Framework file from the analysis cache, as for a file that was deleted."""
658
+ uri = Uri.from_path(path).normalized()
659
+ source = str(uri.to_path())
660
+ try:
661
+ imports_manager = self.get_imports_manager_for_uri(uri)
662
+ imports_manager.data_cache.remove_entries(
663
+ CacheSection.NAMESPACE, [_namespace_cache_key(source, document_type) for document_type in DocumentType]
664
+ )
665
+ imports_manager.remove_cached_resource_docs([str(normalized_path(source))])
666
+ except (SystemExit, KeyboardInterrupt):
667
+ raise
668
+ except BaseException as e:
669
+ ex = e
670
+ self._logger.debug(
671
+ lambda: f"Failed to remove the analysis cache entries of {source}: {ex}",
672
+ context_name="cache",
673
+ )
674
+
675
+ def remove_cached_analyses_of_missing_files(self, folder: WorkspaceFolder) -> None:
676
+ """Remove the entries of the files that do not exist from the analysis cache of a workspace folder."""
677
+ imports_manager = self.get_imports_manager_for_workspace_folder(folder)
678
+ data_cache = imports_manager.data_cache
679
+ try:
680
+ for section, source_of in (
681
+ (CacheSection.NAMESPACE, _namespace_cache_source),
682
+ (CacheSection.RESOURCE, str),
683
+ ):
684
+ missing = [
685
+ entry.entry_name
686
+ for entry in data_cache.list_entries(section)
687
+ if not Path(source_of(entry.entry_name)).exists()
688
+ ]
689
+ if not missing:
690
+ continue
691
+ if section == CacheSection.RESOURCE:
692
+ imports_manager.remove_cached_resource_docs(missing)
693
+ else:
694
+ data_cache.remove_entries(section, missing)
695
+ except (SystemExit, KeyboardInterrupt):
696
+ raise
697
+ except BaseException as e:
698
+ ex = e
699
+ self._logger.debug(
700
+ lambda: f"Failed to remove the cache entries of missing files: {ex}", context_name="cache"
701
+ )
702
+
637
703
  def _save_namespace_to_cache(
638
704
  self,
639
705
  source: str,
@@ -780,7 +846,9 @@ class DocumentsCacheHelper:
780
846
  def calc_cache_path(self, folder_uri: Uri) -> Path:
781
847
  from .data_cache import resolve_cache_base_path
782
848
 
783
- return resolve_cache_base_path(folder_uri.to_path())
849
+ folder = folder_uri.to_path()
850
+ cache_dir = self.analysis_config.cache_dir
851
+ return resolve_cache_base_path(folder / cache_dir if cache_dir else folder)
784
852
 
785
853
  def get_diagnostic_modifier(self, document: TextDocument) -> DiagnosticsModifier:
786
854
  return document.get_cache(self.__get_diagnostic_modifier)
@@ -20,6 +20,12 @@ class Error:
20
20
  VARIABLES_ALREADY_IMPORTED = "VariablesAlreadyImported"
21
21
  LIBRARY_ALREADY_IMPORTED = "LibraryAlreadyImported"
22
22
  LIBRARY_OVERRIDES_BUILTIN = "LibraryOverridesBuiltIn"
23
+ LIBRARY_IMPORT_IGNORED = "LibraryImportIgnored"
24
+ LIBRARY_LOADED_WITHOUT_ARGUMENTS = "LibraryLoadedWithoutArguments"
25
+ LIBRARY_TIMEOUT_ERROR = "LibraryTimeoutError"
26
+ VARIABLES_TIMEOUT_ERROR = "VariablesTimeoutError"
27
+ LIBRARY_EXIT_ERROR = "LibraryExitError"
28
+ VARIABLES_EXIT_ERROR = "VariablesExitError"
23
29
  DEPRECATED_KEYWORD = "DeprecatedKeyword"
24
30
  KEYWORD_CONTAINS_ERRORS = "KeywordContainsErrors"
25
31
  RESERVED_KEYWORD = "ReservedKeyword"
@@ -32,6 +38,7 @@ class Error:
32
38
  DEPRECATED_HYPHEN_TAG = "DeprecatedHyphenTag"
33
39
  DEPRECATED_RETURN_SETTING = "DeprecatedReturnSetting"
34
40
  DEPRECATED_FORCE_TAG = "DeprecatedForceTag"
41
+ TAGS_WITHOUT_EMPTY_ROW = "TagsWithoutEmptyRow"
35
42
  IMPORT_REQUIRES_VALUE = "ImportRequiresValue"
36
43
  KEYWORD_ERROR = "KeywordError"
37
44
  MULTIPLE_KEYWORDS = "MultipleKeywords"