Skip to content

Commit c5dba4f

Browse files
authored
fix: address SonarCloud new code violations (#5116)
1 parent cdb4fc6 commit c5dba4f

29 files changed

Lines changed: 1251 additions & 677 deletions

.config/dictionary.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ FQCNs
66
Fimport
77
LIBYAML
88
LINEINFILE
9+
LINTABLE
910
Lineinfile
1011
Lintable
1112
MYSQLD

src/ansiblelint/cli.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,30 @@ def expand_to_normalized_paths(
6262
config[paths_var] = normalized_paths
6363

6464

65+
def _fatal_config_error(msg: str) -> None:
66+
"""Log a fatal configuration error and terminate the process."""
67+
if any(
68+
isinstance(h, logging.FileHandler)
69+
and h.baseFilename != os.path.abspath(os.devnull)
70+
for h in logging.root.handlers
71+
):
72+
_logger.error(msg)
73+
console_stderr.print(f"[error]{msg}[/]")
74+
sys.exit(RC.INVALID_CONFIG)
75+
76+
77+
def _resolve_config_project_dir(config: dict[str, Any], config_path: str) -> None:
78+
"""Resolve a relative project_dir in config against the config file location."""
79+
if (
80+
"project_dir" in config
81+
and config["project_dir"]
82+
and config["project_dir"][0] not in ("/", "~")
83+
):
84+
config["project_dir"] = (
85+
(Path(config_path).parent / config["project_dir"]).resolve().as_posix()
86+
)
87+
88+
6589
def load_config(
6690
config_file: str | None = None, project_path: str | None = None
6791
) -> tuple[dict[Any, Any], str | None]:
@@ -75,15 +99,7 @@ def load_config(
7599
if config_file:
76100
config_path = os.path.abspath(config_file)
77101
if not os.path.exists(config_path):
78-
msg = f"Config file not found '{config_path}'"
79-
if any(
80-
isinstance(h, logging.FileHandler)
81-
and h.baseFilename != os.path.abspath(os.devnull)
82-
for h in logging.root.handlers
83-
):
84-
_logger.error(msg)
85-
console_stderr.print(f"[error]{msg}[/]")
86-
sys.exit(RC.INVALID_CONFIG)
102+
_fatal_config_error(f"Config file not found '{config_path}'")
87103
config_path = config_path or get_config_path(None, project_path=project_path)
88104
if not config_path or not os.path.exists(config_path):
89105
# a missing default config file should not trigger an error
@@ -98,15 +114,7 @@ def load_config(
98114
)
99115

100116
for error in validate_file_schema(config_lintable):
101-
msg = f"Invalid configuration file {config_path}. {error}"
102-
if any(
103-
isinstance(h, logging.FileHandler)
104-
and h.baseFilename != os.path.abspath(os.devnull)
105-
for h in logging.root.handlers
106-
):
107-
_logger.error(msg)
108-
console_stderr.print(f"[error]{msg}[/]")
109-
sys.exit(RC.INVALID_CONFIG)
117+
_fatal_config_error(f"Invalid configuration file {config_path}. {error}")
110118

111119
config = clean_json(config_lintable.data)
112120
if not isinstance(config, dict):
@@ -116,14 +124,7 @@ def load_config(
116124
config_dir = os.path.dirname(config_path)
117125
expand_to_normalized_paths(config, config_dir)
118126

119-
if (
120-
"project_dir" in config
121-
and config["project_dir"]
122-
and config["project_dir"][0] not in ("/", "~")
123-
):
124-
config["project_dir"] = (
125-
(Path(config_path).parent / config["project_dir"]).resolve().as_posix()
126-
)
127+
_resolve_config_project_dir(config, config_path)
127128

128129
return config, config_path
129130

src/ansiblelint/config.py

Lines changed: 59 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from importlib.metadata import PackageNotFoundError, distribution, version
1515
from pathlib import Path
1616
from typing import Any
17-
from urllib.error import HTTPError, URLError
17+
from urllib.error import URLError
1818

1919
from packaging.version import Version
2020

@@ -297,6 +297,57 @@ def get_deps_versions() -> dict[str, Version | None]:
297297
return result
298298

299299

300+
def _version_cache_needs_refresh(cache_file: str) -> bool:
301+
if not os.path.exists(cache_file):
302+
return True
303+
age = time.time() - os.path.getmtime(cache_file)
304+
return age >= 24 * 60 * 60
305+
306+
307+
def _load_version_cache(cache_file: str) -> dict[str, Any]:
308+
with open(cache_file, encoding="utf-8") as f:
309+
return json.load(f)
310+
311+
312+
def _fetch_latest_release(cache_file: str) -> dict[str, Any]:
313+
release_url = "https://api.github.com/repos/ansible/ansible-lint/releases/latest"
314+
if not release_url.startswith("https://"): # pragma: no cover (ruff compatibility)
315+
msg = "release_url must start with https://"
316+
raise ValueError(msg)
317+
try:
318+
with urllib.request.urlopen(release_url) as url:
319+
data = json.load(url)
320+
with open(cache_file, "w", encoding="utf-8") as f:
321+
json.dump(data, f)
322+
except (URLError, HTTPException) as exc: # pragma: no cover
323+
_logger.debug(
324+
"Unable to fetch latest version from %s due to: %s",
325+
release_url,
326+
exc,
327+
)
328+
return {}
329+
else:
330+
return data
331+
332+
333+
def _format_version_upgrade_message(
334+
current_version: Version,
335+
data: dict[str, Any],
336+
pip: str,
337+
) -> str:
338+
if not data:
339+
return ""
340+
html_url = data["html_url"]
341+
new_version = Version(data["tag_name"][1:]) # removing v prefix from tag
342+
343+
if current_version > new_version:
344+
return "[dim]You are using a pre-release version of ansible-lint.[/]"
345+
if current_version < new_version:
346+
msg = f"""[warning]A new release of ansible-lint is available: [warning]{current_version}[/] → [success][link={html_url}]{new_version}[/link][/][/]"""
347+
return f"{msg} Upgrade by running: [info]{pip}[/]"
348+
return ""
349+
350+
300351
def get_version_warning() -> str:
301352
"""Display warning if current version is outdated."""
302353
# 0.1dev1 is special fallback version
@@ -308,51 +359,20 @@ def get_version_warning() -> str:
308359
if not pip:
309360
return ""
310361

311-
msg = ""
312-
data = {}
313362
current_version = Version(__version__)
314363

315364
if not os.path.exists(CACHE_DIR): # pragma: no cover
316365
os.makedirs(CACHE_DIR)
317366
cache_file = f"{CACHE_DIR}/latest.json"
318-
refresh = True
367+
refresh = _version_cache_needs_refresh(cache_file)
368+
data: dict[str, Any] = {}
319369
if os.path.exists(cache_file):
320-
age = time.time() - os.path.getmtime(cache_file)
321-
if age < 24 * 60 * 60:
322-
refresh = False
323-
with open(cache_file, encoding="utf-8") as f:
324-
data = json.load(f)
370+
data = _load_version_cache(cache_file)
325371

326372
if not options.offline and (refresh or not data):
327-
release_url = (
328-
"https://api.github.com/repos/ansible/ansible-lint/releases/latest"
329-
)
330-
if not release_url.startswith(
331-
"https://"
332-
): # pragma: no cover (ruff compatibility)
333-
msg = "release_url must start with https://"
334-
raise ValueError(msg)
335-
try:
336-
with urllib.request.urlopen(release_url) as url:
337-
data = json.load(url)
338-
with open(cache_file, "w", encoding="utf-8") as f:
339-
json.dump(data, f)
340-
except (URLError, HTTPError, HTTPException) as exc: # pragma: no cover
341-
_logger.debug(
342-
"Unable to fetch latest version from %s due to: %s",
343-
release_url,
344-
exc,
345-
)
373+
fetched = _fetch_latest_release(cache_file)
374+
if not fetched:
346375
return ""
376+
data = fetched
347377

348-
if data:
349-
html_url = data["html_url"]
350-
new_version = Version(data["tag_name"][1:]) # removing v prefix from tag
351-
352-
if current_version > new_version:
353-
msg = "[dim]You are using a pre-release version of ansible-lint.[/]"
354-
elif current_version < new_version:
355-
msg = f"""[warning]A new release of ansible-lint is available: [warning]{current_version}[/] → [success][link={html_url}]{new_version}[/link][/][/]"""
356-
msg += f" Upgrade by running: [info]{pip}[/]"
357-
358-
return msg
378+
return _format_version_upgrade_message(current_version, data, pip)

src/ansiblelint/file_utils.py

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import sys
99
from collections import defaultdict
1010
from contextlib import contextmanager
11+
from functools import lru_cache
1112
from pathlib import Path
1213
from tempfile import NamedTemporaryFile
1314
from typing import TYPE_CHECKING, Any, cast
@@ -149,49 +150,58 @@ def expand_paths_vars(paths: list[str]) -> list[str]:
149150
return paths
150151

151152

152-
def kind_from_path(path: Path, *, base: bool = False) -> FileType:
153-
"""Determine the file kind based on its name.
153+
@lru_cache(maxsize=128)
154+
def _get_project_root_for_dir(dir_path: str) -> Path:
155+
"""Cache project-root discovery per directory to avoid repeated FS walks."""
156+
project_root, _ = find_project_root([dir_path])
157+
return project_root
154158

155-
When called with base=True, it will return the base file type instead
156-
of the explicit one. That is expected to return 'yaml' for any yaml files.
157-
"""
159+
160+
def _resolve_kind_glob_path(path: Path) -> wcmatch.pathlib.PurePath:
161+
"""Return a PurePath usable for glob matching against kind patterns."""
158162
# We attempt to use a relative path to the project root for glob matching.
159163
# This prevents parent directory names (like 'tasks') from triggering
160164
# false positives in kind discovery. See #4763.
161165
try:
162-
project_root, _ = find_project_root([str(path)])
166+
# Project root is shared by siblings; cache by directory to cut FS walks.
167+
lookup_path = path if path.is_dir() else path.parent
168+
project_root = _get_project_root_for_dir(str(lookup_path.resolve()))
163169
# .resolve() ensures we handle symlinks and double-dots correctly
164170
rel_path = path.resolve().relative_to(project_root.resolve())
165-
pathex = wcmatch.pathlib.PurePath(str(rel_path))
171+
return wcmatch.pathlib.PurePath(str(rel_path))
166172
except (ValueError, RuntimeError):
167173
# Fallback to absolute if the file is outside the project root or can't be found
168-
pathex = wcmatch.pathlib.PurePath(str(path.absolute().resolve()))
169-
kinds = options.kinds if not base else BASE_KINDS
174+
return wcmatch.pathlib.PurePath(str(path.absolute().resolve()))
175+
176+
177+
def _match_kind_from_globs(
178+
pathex: wcmatch.pathlib.PurePath,
179+
kinds: list[dict[str, str]],
180+
path: Path,
181+
) -> FileType | None:
182+
"""Return the first kind whose glob pattern matches path, if any."""
170183
for entry in kinds:
171184
for k, v in entry.items():
172-
if pathex.globmatch(
185+
if not pathex.globmatch(
173186
v,
174187
flags=(
175188
wcmatch.pathlib.GLOBSTAR
176189
| wcmatch.pathlib.BRACE
177190
| wcmatch.pathlib.DOTGLOB
178191
),
179192
):
180-
matched_kind = str(k)
181-
# Namespace folders under roles/ can match **/roles/*/ without
182-
# being role roots themselves. See #5079.
183-
if (
184-
matched_kind == "role"
185-
and path.is_dir()
186-
and not _has_role_subdirs(path)
187-
):
188-
continue
189-
return matched_kind # type: ignore[return-value]
193+
continue
194+
matched_kind = str(k)
195+
# Namespace folders under roles/ can match **/roles/*/ without
196+
# being role roots themselves. See #5079.
197+
if matched_kind == "role" and path.is_dir() and not _has_role_subdirs(path):
198+
continue
199+
return matched_kind # type: ignore[return-value]
200+
return None
190201

191-
if base:
192-
# Unknown base file type is default
193-
return ""
194202

203+
def _fallback_kind_from_path(path: Path) -> FileType:
204+
"""Determine a fallback kind for a path not matched by any glob pattern."""
195205
if path.is_dir() and _has_role_subdirs(path):
196206
return "role"
197207

@@ -213,6 +223,25 @@ def kind_from_path(path: Path, *, base: bool = False) -> FileType:
213223
return ""
214224

215225

226+
def kind_from_path(path: Path, *, base: bool = False) -> FileType:
227+
"""Determine the file kind based on its name.
228+
229+
When called with base=True, it will return the base file type instead
230+
of the explicit one. That is expected to return 'yaml' for any yaml files.
231+
"""
232+
pathex = _resolve_kind_glob_path(path)
233+
kinds = options.kinds if not base else BASE_KINDS
234+
matched_kind = _match_kind_from_globs(pathex, kinds, path)
235+
if matched_kind is not None:
236+
return matched_kind
237+
238+
if base:
239+
# Unknown base file type is default
240+
return ""
241+
242+
return _fallback_kind_from_path(path)
243+
244+
216245
# pylint: disable=too-many-instance-attributes
217246
class Lintable:
218247
"""Defines a file/folder that can be linted.

src/ansiblelint/loaders.py

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,8 @@ def get_ignore_rule(rule: str, qualifiers: str) -> IgnoreRule:
6767
return IgnoreRule(rule, frozenset(s))
6868

6969

70-
def load_ignore_txt(filepath: Path | None = None) -> dict[str, set[IgnoreRule]]:
71-
"""Return a list of rules to ignore."""
72-
result = defaultdict(set)
73-
70+
def _resolve_ignore_file(filepath: Path | None) -> str | None:
71+
"""Return the path to the ignore file to use, if any."""
7472
ignore_file = None
7573

7674
if filepath:
@@ -83,21 +81,38 @@ def load_ignore_txt(filepath: Path | None = None) -> dict[str, set[IgnoreRule]]:
8381
elif os.path.isfile(IGNORE_FILE.alternative):
8482
ignore_file = IGNORE_FILE.alternative
8583

84+
return ignore_file
85+
86+
87+
def _parse_ignore_file(
88+
ignore_file: str,
89+
result: defaultdict[str, set[IgnoreRule]],
90+
) -> None:
91+
"""Parse an ignore file, populating result in place."""
92+
with open(ignore_file, encoding="utf-8") as ignore_file_h:
93+
_logger.debug("Loading ignores from '%s'", ignore_file)
94+
for line in ignore_file_h:
95+
entry = line.split("#")[0].rstrip()
96+
if not entry:
97+
continue
98+
try:
99+
fields = entry.split()
100+
path = fields[0]
101+
rule = fields[1]
102+
qualifiers = fields[2] if len(fields) == 3 else ""
103+
result[path].add(get_ignore_rule(rule, qualifiers))
104+
except ValueError as exc: # pragma: no cover
105+
msg = f"Unable to parse line '{line}' from {ignore_file} file."
106+
raise RuntimeError(msg) from exc
107+
108+
109+
def load_ignore_txt(filepath: Path | None = None) -> dict[str, set[IgnoreRule]]:
110+
"""Return a list of rules to ignore."""
111+
result: defaultdict[str, set[IgnoreRule]] = defaultdict(set)
112+
113+
ignore_file = _resolve_ignore_file(filepath)
86114
if ignore_file:
87-
with open(ignore_file, encoding="utf-8") as ignore_file_h:
88-
_logger.debug("Loading ignores from '%s'", ignore_file)
89-
for line in ignore_file_h:
90-
entry = line.split("#")[0].rstrip()
91-
if entry:
92-
try:
93-
fields = entry.split()
94-
path = fields[0]
95-
rule = fields[1]
96-
qualifiers = fields[2] if len(fields) == 3 else ""
97-
result[path].add(get_ignore_rule(rule, qualifiers))
98-
except ValueError as exc: # pragma: no cover
99-
msg = f"Unable to parse line '{line}' from {ignore_file} file."
100-
raise RuntimeError(msg) from exc
115+
_parse_ignore_file(ignore_file, result)
101116
return result
102117

103118

0 commit comments

Comments
 (0)