Skip to content

Commit d27e59f

Browse files
authored
feat: Add full typing safety with mypy checks (#1662)
* feat: Add complete typing support with mypy
1 parent 42045dc commit d27e59f

File tree

9 files changed

+87
-76
lines changed

9 files changed

+87
-76
lines changed

.pre-commit-config.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,23 @@ repos:
44
hooks:
55
- id: ruff
66
- id: ruff-format
7+
8+
- repo: https://github.com/pre-commit/mirrors-mypy
9+
rev: v1.19.1
10+
hooks:
11+
- id: mypy
12+
args: ["--config-file=pyproject.toml", "src/robocop"]
13+
pass_filenames: false
14+
additional_dependencies:
15+
- typer-slim>=0.12.5
16+
- jinja2>=3.1.4
17+
- rich>=10.11.0
18+
- tomli>=2.0.0
19+
- tomli-w>=1.0
20+
- pathspec>=0.12
21+
- platformdirs>=4.3
22+
- pytz>=2022.7
23+
- types-jinja2
24+
- types-pytz
25+
- fastmcp>=2.13.0
26+
- msgpack-types

src/robocop/cache.py

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
from dataclasses import dataclass, field
11+
from functools import cached_property
1112
from typing import TYPE_CHECKING, Any
1213

1314
import msgpack
@@ -259,39 +260,33 @@ def __init__(
259260
self.enabled = enabled
260261
self.cache_dir = cache_dir
261262
self.verbose = verbose
262-
self._data: CacheData | None = None
263263
self._dirty = False
264264
self._path_cache: dict[Path, str] = {} # Instance-bound path normalization cache
265265

266-
@property
266+
@cached_property
267267
def data(self) -> CacheData:
268268
"""Get cache data, loading from disk if needed."""
269-
if self._data is None:
270-
self._load()
271-
return self._data
269+
return self._load()
272270

273-
def _load(self) -> None:
271+
def _load(self) -> CacheData:
274272
"""Load cache from disk."""
275273
# Handle missing cache directory (first run)
276274
if not self.cache_dir.exists():
277-
self._data = CacheData()
278-
return
275+
return CacheData()
279276

280277
cache_file = self.cache_dir / defaults.CACHE_FILE_NAME
281278

282279
if not cache_file.is_file():
283-
self._data = CacheData()
284-
return
280+
return CacheData()
285281

286282
try:
287283
raw_data = msgpack.unpackb(cache_file.read_bytes(), raw=False, strict_map_key=False)
288284

289285
# Invalidate if version changed
290286
if raw_data.get("robocop_version") != __version__:
291-
self._data = CacheData()
292-
return
287+
return CacheData()
293288

294-
self._data = CacheData.from_dict(raw_data)
289+
return CacheData.from_dict(raw_data)
295290
except (
296291
msgpack.exceptions.UnpackException,
297292
msgpack.exceptions.ExtraData,
@@ -300,11 +295,11 @@ def _load(self) -> None:
300295
OSError,
301296
):
302297
# Corrupted cache - start fresh
303-
self._data = CacheData()
298+
return CacheData()
304299

305300
def save(self) -> None:
306301
"""Save cache to disk if modified."""
307-
should_skip = not self.enabled or not self._dirty or self._data is None
302+
should_skip = not self.enabled or not self._dirty
308303
if should_skip:
309304
return
310305

@@ -313,7 +308,7 @@ def save(self) -> None:
313308
cache_file = self.cache_dir / defaults.CACHE_FILE_NAME
314309

315310
try:
316-
cache_file.write_bytes(msgpack.packb(self._data.to_dict(), use_bin_type=True))
311+
cache_file.write_bytes(msgpack.packb(self.data.to_dict(), use_bin_type=True))
317312
self._dirty = False
318313
except OSError as err:
319314
if self.verbose:
@@ -331,7 +326,7 @@ def _create_gitignore(self) -> None:
331326

332327
def invalidate_all(self) -> None:
333328
"""Clear the entire cache."""
334-
self._data = CacheData()
329+
self.data = CacheData()
335330
self._dirty = True
336331

337332
def _normalize_path(self, path: Path) -> str:

src/robocop/config/builder.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def resolve(
5858
if file is not None:
5959
value = getattr(file, attr, None)
6060
if value is not None:
61-
return value
61+
return value # type: ignore[no-any-return]
6262

6363
return default
6464

@@ -67,7 +67,7 @@ def merge_lists(
6767
file: object | None,
6868
cli: object | None,
6969
attr: str,
70-
) -> list:
70+
) -> list[str]:
7171
"""
7272
Resolve and merge configuration values.
7373

src/robocop/config/manager.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,16 @@ def __init__(
120120
self.overwrite_config = overwrite_config
121121
self.ignore_git_dir = ignore_git_dir
122122
self.ignore_file_config = ignore_file_config
123-
self.force_exclude = force_exclude
124123
self.skip_gitignore = skip_gitignore
125124
self.gitignore_resolver = GitIgnoreResolver()
126125
self.overridden_config = config is not None
127126
self.root = root or Path.cwd()
128-
self.sources = sources
129-
self.default_config: Config = self.get_default_config(config)
130-
self._paths: dict[Path, SourceFile] | None = None
127+
self.default_config: Config = self.get_default_config(config, sources)
128+
self.sources = sources if sources else self.default_config.sources
129+
# ignore file filters on paths passed directly
130+
self.ignore_file_filters = not force_exclude and bool(sources)
131+
self._paths: dict[Path, SourceFile] = {}
132+
self.resolved_paths = False
131133
self._cache: RobocopCache | None = None
132134

133135
@property
@@ -145,23 +147,24 @@ def cache(self) -> RobocopCache:
145147
@property
146148
def paths(self) -> Generator[SourceFile, None, None]:
147149
# TODO: what if we provide the same path twice - tests
148-
if self._paths is None:
149-
self._paths = {}
150-
sources: list[str | Path] = list(self.sources) if self.sources else list(self.default_config.sources)
151-
ignore_file_filters = not self.force_exclude and bool(sources)
152-
self.resolve_paths(sources, ignore_file_filters=ignore_file_filters)
150+
if not self.resolved_paths:
151+
self.resolve_paths(self.sources, ignore_file_filters=self.ignore_file_filters)
153152
yield from self._paths.values()
154153

155-
def get_default_config(self, config_path: Path | None) -> Config:
154+
def get_default_config(self, config_path: Path | None, sources: list[str] | None) -> Config:
156155
"""Get the default config either from --config option or from the cli."""
157156
if config_path:
158157
configuration = read_toml_config(config_path)
159158
if configuration is not None: # TODO: should raise
160159
config_file_raw = RawConfig.from_dict(configuration, config_path.resolve())
161160
return self.config_builder.from_raw(self.overwrite_config, config_file_raw)
162161
if not self.ignore_file_config:
163-
sources = [Path(path).resolve() for path in self.sources] if self.sources else [Path.cwd()]
164-
directories = files.get_common_parent_dirs(sources)
162+
if sources:
163+
sources_paths: list[Path] = [Path(path) for path in sources]
164+
paths = [path.resolve() for path in sources_paths if not path.is_symlink()]
165+
else:
166+
paths = [Path.cwd().resolve()]
167+
directories = files.get_common_parent_dirs(paths)
165168
return self.find_config_in_dirs(directories, default=None)
166169
return self.config_builder.from_raw(self.overwrite_config, None)
167170

@@ -231,7 +234,7 @@ def get_config_for_source_file(self, source_file: Path) -> Config:
231234

232235
def resolve_paths(
233236
self,
234-
sources: list[str | Path] | Generator[Path, None, None],
237+
sources: list[Path] | list[str],
235238
ignore_file_filters: bool = False,
236239
) -> None:
237240
"""
@@ -266,6 +269,6 @@ def resolve_paths(
266269
if self.gitignore_resolver.path_excluded(source_not_resolved, source_gitignore):
267270
continue
268271
if source.is_dir():
269-
self.resolve_paths(source.iterdir())
272+
self.resolve_paths(list(source.iterdir()))
270273
elif source.is_file():
271274
self._paths[source] = SourceFile(path=source, config=config)

src/robocop/linter/fix.py

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class TextEditKind(Enum):
2525
"""
2626

2727
REPLACEMENT = "replace"
28+
REPLACEMENT_LINES = "replace_lines"
2829
INSERTION = "insert"
2930
DELETION = "delete"
3031

@@ -54,18 +55,11 @@ class TextEdit:
5455
rule_id: str
5556
rule_name: str
5657
start_line: int
57-
start_col: int | None
58-
end_line: int | None
59-
end_col: int | None
60-
replacement: str | None
61-
62-
@property
63-
def kind(self) -> TextEditKind:
64-
if self.replacement is None:
65-
return TextEditKind.DELETION
66-
if self.end_line is None:
67-
return TextEditKind.INSERTION
68-
return TextEditKind.REPLACEMENT
58+
start_col: int
59+
end_line: int
60+
end_col: int
61+
replacement: str
62+
kind: TextEditKind = TextEditKind.REPLACEMENT
6963

7064
@classmethod
7165
def replace_at_range(cls, rule_id: str, rule_name: str, diag_range: Range, replacement: str) -> TextEdit:
@@ -77,6 +71,7 @@ def replace_at_range(cls, rule_id: str, rule_name: str, diag_range: Range, repla
7771
end_line=diag_range.end.line,
7872
end_col=diag_range.end.character,
7973
replacement=replacement,
74+
kind=TextEditKind.REPLACEMENT,
8075
)
8176

8277
@classmethod
@@ -86,10 +81,11 @@ def replace_lines(cls, rule_id: str, rule_name: str, start_line: int, end_line:
8681
rule_id=rule_id,
8782
rule_name=rule_name,
8883
start_line=start_line,
89-
start_col=None,
84+
start_col=1,
9085
end_line=end_line,
91-
end_col=None,
86+
end_col=1,
9287
replacement=replacement,
88+
kind=TextEditKind.REPLACEMENT_LINES,
9389
)
9490

9591
@classmethod
@@ -99,10 +95,11 @@ def remove_at_range(cls, rule_id: str, rule_name: str, diag_range: Range) -> Tex
9995
rule_id=rule_id,
10096
rule_name=rule_name,
10197
start_line=diag_range.start.line,
102-
start_col=None,
98+
start_col=1,
10399
end_line=diag_range.end.line,
104-
end_col=None,
105-
replacement=None,
100+
end_col=1,
101+
replacement="",
102+
kind=TextEditKind.DELETION,
106103
)
107104

108105
@classmethod
@@ -112,10 +109,11 @@ def insert_at_range(cls, rule_id: str, rule_name: str, diag_range: Range, replac
112109
rule_id=rule_id,
113110
rule_name=rule_name,
114111
start_line=diag_range.start.line,
115-
start_col=None,
116-
end_line=None,
117-
end_col=None,
112+
start_col=1,
113+
end_line=1,
114+
end_col=1,
118115
replacement=replacement,
116+
kind=TextEditKind.INSERTION,
119117
)
120118

121119

@@ -288,17 +286,17 @@ def _apply_edit(lines: list[str], edit: TextEdit) -> None:
288286
289287
"""
290288
# TODO: different kind of edits should have different apply_edit
291-
if edit.kind == TextEditKind.REPLACEMENT:
292-
if edit.end_line > len(lines) or edit.start_line < 1:
293-
return
294-
start_line_idx = edit.start_line - 1
295-
end_line_idx = edit.end_line - 1
296-
if edit.start_col is None or edit.end_col is None: # replace_lines
297-
lines[start_line_idx : end_line_idx + 1] = edit.replacement.splitlines(keepends=True)
298-
return
289+
if edit.end_line > len(lines) or edit.start_line < 1:
290+
return
291+
292+
start_line_idx = edit.start_line - 1
293+
end_line_idx = edit.end_line - 1
294+
295+
if edit.kind == TextEditKind.REPLACEMENT_LINES:
296+
lines[start_line_idx : end_line_idx + 1] = edit.replacement.splitlines(keepends=True)
297+
elif edit.kind == TextEditKind.REPLACEMENT:
299298
start_col_idx = edit.start_col - 1
300299
end_col_idx = edit.end_col - 1
301-
302300
if start_line_idx == end_line_idx: # single line
303301
line = lines[edit.start_line - 1]
304302
new_line = line[:start_col_idx] + edit.replacement + line[end_col_idx:]
@@ -307,8 +305,6 @@ def _apply_edit(lines: list[str], edit: TextEdit) -> None:
307305
# When edit is multiline, we replace the lines fully
308306
lines[start_line_idx : end_line_idx + 2] = edit.replacement.splitlines(keepends=True)
309307
elif edit.kind == TextEditKind.INSERTION:
310-
start_line_idx = edit.start_line - 1
311308
lines[start_line_idx:start_line_idx] = edit.replacement.splitlines(keepends=True)
312-
else: # edit.kind == TextEditKind.DELETION
313-
start_line_idx = edit.start_line - 1
309+
elif edit.kind == TextEditKind.DELETION:
314310
del lines[start_line_idx : edit.end_line]

src/robocop/linter/rules/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,9 +455,9 @@ def configure(self, param: str, value: str) -> None:
455455
f" {configurables_text}"
456456
)
457457
self.config[param].value = value
458-
# If you want to use the same parameter name as Rule attribute (for example severity), you need to skip getattr
459458
if param == "severity":
460-
self.severity = self.config[param].value
459+
# To use parameter also as rule attribute (for example severity), you need to skip getattr
460+
self.severity = RuleSeverity.parser(value)
461461

462462
def available_configurables(self, include_severity: bool = True) -> tuple[int, str]:
463463
params = []

src/robocop/runtime/resolver.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,24 +114,23 @@ def is_rule(rule_class_def: tuple[str, type]) -> bool:
114114

115115

116116
def can_run_in_robot_version(formatter: Formatter, overwritten: bool, target_version: int) -> bool:
117-
if getattr(formatter, "MIN_VERSION", None) is None:
118-
return True
119-
if target_version >= formatter.MIN_VERSION:
117+
min_version = getattr(formatter, "MIN_VERSION", None)
118+
if not min_version or target_version >= min_version:
120119
return True
121120
if overwritten:
122121
# --select FormatterDisabledInVersion or --configure FormatterDisabledInVersion.enabled=True
123122
if target_version == ROBOT_VERSION.major:
124123
click.echo(
125-
f"{formatter.__class__.__name__} formatter requires Robot Framework {formatter.MIN_VERSION}.* "
124+
f"{formatter.__class__.__name__} formatter requires Robot Framework {min_version}.* "
126125
f"version but you have {ROBOT_VERSION} installed. "
127126
f"Upgrade installed Robot Framework if you want to use this formatter.",
128127
err=True,
129128
)
130129
else:
131130
click.echo(
132-
f"{formatter.__class__.__name__} formatter requires Robot Framework {formatter.MIN_VERSION}.* "
131+
f"{formatter.__class__.__name__} formatter requires Robot Framework {min_version}.* "
133132
f"version but you set --target-version rf{target_version}. "
134-
f"Set --target-version to {formatter.MIN_VERSION} or do not forcefully enable this formatter "
133+
f"Set --target-version to {min_version} or do not forcefully enable this formatter "
135134
f"with --select / enable parameter.",
136135
err=True,
137136
)

src/robocop/source_file.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def _read_lines(self) -> list[str]:
111111
def _load_model(self, path_or_text: Path | str) -> File:
112112
"""Determine the correct model loader based on the file type and loads it."""
113113
if "__init__" in self.path.name:
114-
loader: Callable = get_init_model
114+
loader: Callable[..., File] = get_init_model
115115
elif self.path.suffix == ".resource":
116116
loader = get_resource_model
117117
else:

tests/cache/test_cache.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -850,8 +850,6 @@ def test_cache_data_lazy_loading(self, tmp_path: Path):
850850
)
851851

852852
cache = RobocopCache(cache_dir=cache_dir, enabled=True, verbose=False)
853-
# Data should not be loaded yet
854-
assert cache._data is None # noqa: SLF001
855853

856854
# Accessing .data triggers load
857855
data = cache.data

0 commit comments

Comments
 (0)