pycmdcheck 0.1.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 (41) hide show
  1. pycmdcheck/__init__.py +104 -0
  2. pycmdcheck/__main__.py +6 -0
  3. pycmdcheck/ast_cache.py +45 -0
  4. pycmdcheck/checks/__init__.py +1 -0
  5. pycmdcheck/checks/base.py +143 -0
  6. pycmdcheck/checks/build.py +101 -0
  7. pycmdcheck/checks/changelog.py +101 -0
  8. pycmdcheck/checks/ci.py +87 -0
  9. pycmdcheck/checks/citation.py +102 -0
  10. pycmdcheck/checks/community.py +108 -0
  11. pycmdcheck/checks/dependencies.py +309 -0
  12. pycmdcheck/checks/docs.py +305 -0
  13. pycmdcheck/checks/doctests.py +129 -0
  14. pycmdcheck/checks/formatting.py +200 -0
  15. pycmdcheck/checks/imports.py +239 -0
  16. pycmdcheck/checks/license.py +304 -0
  17. pycmdcheck/checks/linting.py +304 -0
  18. pycmdcheck/checks/metadata.py +233 -0
  19. pycmdcheck/checks/py_typed.py +207 -0
  20. pycmdcheck/checks/python_versions.py +188 -0
  21. pycmdcheck/checks/structure.py +282 -0
  22. pycmdcheck/checks/tests.py +230 -0
  23. pycmdcheck/checks/typing.py +257 -0
  24. pycmdcheck/checks/urls.py +124 -0
  25. pycmdcheck/checks/version.py +184 -0
  26. pycmdcheck/cli.py +296 -0
  27. pycmdcheck/config.py +194 -0
  28. pycmdcheck/constants.py +29 -0
  29. pycmdcheck/discovery.py +127 -0
  30. pycmdcheck/package_layout.py +234 -0
  31. pycmdcheck/profiles.py +158 -0
  32. pycmdcheck/py.typed +0 -0
  33. pycmdcheck/pyproject_reader.py +226 -0
  34. pycmdcheck/results.py +320 -0
  35. pycmdcheck/runner.py +236 -0
  36. pycmdcheck/subprocess_runner.py +166 -0
  37. pycmdcheck-0.1.0.dist-info/METADATA +346 -0
  38. pycmdcheck-0.1.0.dist-info/RECORD +41 -0
  39. pycmdcheck-0.1.0.dist-info/WHEEL +4 -0
  40. pycmdcheck-0.1.0.dist-info/entry_points.txt +24 -0
  41. pycmdcheck-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,230 @@
1
+ """Test runner check.
2
+
3
+ This module provides the TestsCheck class which runs package tests
4
+ using pytest or unittest and reports the results.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pycmdcheck.checks.base import BaseCheck
11
+ from pycmdcheck.constants import LONG_TIMEOUT, MAX_DETAIL_LINES
12
+ from pycmdcheck.results import CheckResult, CheckStatus
13
+ from pycmdcheck.subprocess_runner import run_tool, sanitize_args, tool_available
14
+
15
+
16
+ class TestsCheck(BaseCheck):
17
+ """Run package tests and report results.
18
+
19
+ Executes the package's test suite using the configured test runner
20
+ (pytest by default) and reports pass/fail status. Supports pytest
21
+ and unittest runners.
22
+
23
+ Attributes:
24
+ name: The check identifier ("tests").
25
+ description: Human-readable description of this check.
26
+
27
+ Configuration Options:
28
+ runner (str): Test runner to use ("pytest" or "unittest").
29
+ Defaults to "pytest".
30
+ args (list[str]): Additional arguments to pass to the test runner.
31
+
32
+ Examples:
33
+ Run tests with default settings (pytest):
34
+
35
+ >>> from pathlib import Path
36
+ >>> check = TestsCheck()
37
+ >>> result = check.run(Path("."), {"enabled": True})
38
+ >>> result.name
39
+ 'tests'
40
+
41
+ Configure to use unittest:
42
+
43
+ >>> config = {"enabled": True, "runner": "unittest"}
44
+ >>> result = check.run(Path("."), config)
45
+
46
+ Pass additional pytest arguments:
47
+
48
+ >>> config = {"enabled": True, "args": ["-x", "--cov"]}
49
+ >>> result = check.run(Path("."), config)
50
+ """
51
+
52
+ name = "tests"
53
+ description = "Run package tests (pytest/unittest)"
54
+
55
+ def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
56
+ """Run tests and return results.
57
+
58
+ Executes the test suite in the package's tests/ or test/ directory.
59
+ The test runner is determined by the ``runner`` config option.
60
+
61
+ Args:
62
+ package_path: Path to the package directory containing tests.
63
+ config: Configuration dictionary with options:
64
+
65
+ - runner (str): "pytest" or "unittest" (default: "pytest")
66
+ - args (list[str]): Additional arguments for the runner
67
+
68
+ Returns:
69
+ A CheckResult with:
70
+
71
+ - OK status if all tests pass
72
+ - WARNING status if no tests found
73
+ - ERROR status if tests fail or runner not installed
74
+ - SKIPPED status if the test runner is not installed
75
+ """
76
+ runner = config.get("runner", "pytest")
77
+
78
+ # Check if test directory exists
79
+ test_dirs = ["tests", "test"]
80
+ test_dir_found = None
81
+ for test_dir in test_dirs:
82
+ if (package_path / test_dir).is_dir():
83
+ test_dir_found = test_dir
84
+ break
85
+
86
+ # Also check for test files at root
87
+ test_files = list(package_path.glob("test_*.py"))
88
+
89
+ if not test_dir_found and not test_files:
90
+ return CheckResult(
91
+ name=self.name,
92
+ status=CheckStatus.WARNING,
93
+ message="No tests found",
94
+ details=["Consider adding tests in a 'tests/' directory"],
95
+ )
96
+
97
+ preamble = [f"Using test runner: {runner}"]
98
+
99
+ if runner == "pytest":
100
+ return self._run_pytest(package_path, config, preamble)
101
+ elif runner == "unittest":
102
+ return self._run_unittest(package_path, config, preamble)
103
+ else:
104
+ return CheckResult(
105
+ name=self.name,
106
+ status=CheckStatus.ERROR,
107
+ message=f"Unknown test runner: {runner}",
108
+ details=["Supported runners: pytest, unittest"],
109
+ )
110
+
111
+ def _run_pytest(
112
+ self, package_path: Path, config: dict[str, Any], preamble: list[str]
113
+ ) -> CheckResult:
114
+ """Run pytest."""
115
+ if not tool_available("pytest"):
116
+ return CheckResult(
117
+ name=self.name,
118
+ status=CheckStatus.SKIPPED,
119
+ message="pytest not installed",
120
+ details=["Install pytest: pip install pytest"],
121
+ )
122
+
123
+ args = sanitize_args(config.get("args", []))
124
+ cmd = ["pytest", "-v", "--tb=short", *args]
125
+
126
+ result = run_tool(cmd, cwd=package_path, timeout=LONG_TIMEOUT)
127
+
128
+ if result.timed_out:
129
+ return CheckResult(
130
+ name=self.name,
131
+ status=CheckStatus.ERROR,
132
+ message="Tests timed out",
133
+ details=[f"Tests took longer than {LONG_TIMEOUT} seconds"],
134
+ )
135
+
136
+ if result.error is not None:
137
+ return CheckResult(
138
+ name=self.name,
139
+ status=CheckStatus.ERROR,
140
+ message="Failed to run tests",
141
+ details=[result.error],
142
+ )
143
+
144
+ if result.success:
145
+ # Parse pytest output for summary
146
+ details = list(preamble)
147
+ for line in result.stdout.split("\n"):
148
+ if "passed" in line or "skipped" in line:
149
+ details.append(line.strip())
150
+ break
151
+
152
+ return CheckResult(
153
+ name=self.name,
154
+ status=CheckStatus.OK,
155
+ message="All tests passed",
156
+ details=details,
157
+ )
158
+
159
+ # Tests failed
160
+ output = result.stdout + result.stderr
161
+ failure_lines: list[str] = []
162
+ for line in output.split("\n"):
163
+ if "FAILED" in line or "ERROR" in line:
164
+ failure_lines.append(line.strip())
165
+
166
+ details = [*preamble, *failure_lines[:MAX_DETAIL_LINES]]
167
+ if len(failure_lines) > MAX_DETAIL_LINES:
168
+ details.append("... (more failures omitted)")
169
+
170
+ return CheckResult(
171
+ name=self.name,
172
+ status=CheckStatus.ERROR,
173
+ message="Tests failed",
174
+ details=details,
175
+ )
176
+
177
+ def _run_unittest(
178
+ self, package_path: Path, config: dict[str, Any], preamble: list[str]
179
+ ) -> CheckResult:
180
+ """Run unittest discovery."""
181
+ args = sanitize_args(config.get("args", []))
182
+ cmd = ["python", "-m", "unittest", "discover", "-v", *args]
183
+
184
+ result = run_tool(cmd, cwd=package_path, timeout=LONG_TIMEOUT)
185
+
186
+ if result.timed_out:
187
+ return CheckResult(
188
+ name=self.name,
189
+ status=CheckStatus.ERROR,
190
+ message="Tests timed out",
191
+ details=[f"Tests took longer than {LONG_TIMEOUT} seconds"],
192
+ )
193
+
194
+ if result.error is not None:
195
+ return CheckResult(
196
+ name=self.name,
197
+ status=CheckStatus.ERROR,
198
+ message="Failed to run tests",
199
+ details=[result.error],
200
+ )
201
+
202
+ if result.success:
203
+ # Parse output for test count (unittest outputs to stderr)
204
+ details = list(preamble)
205
+ for line in result.stderr.split("\n"):
206
+ if "Ran" in line:
207
+ details.append(line.strip())
208
+ break
209
+
210
+ return CheckResult(
211
+ name=self.name,
212
+ status=CheckStatus.OK,
213
+ message="All tests passed",
214
+ details=details,
215
+ )
216
+
217
+ # Parse failures from stderr
218
+ failure_lines: list[str] = []
219
+ for line in result.stderr.split("\n"):
220
+ if "FAIL:" in line or "ERROR:" in line:
221
+ failure_lines.append(line.strip())
222
+
223
+ details = [*preamble, *failure_lines[:MAX_DETAIL_LINES]]
224
+
225
+ return CheckResult(
226
+ name=self.name,
227
+ status=CheckStatus.ERROR,
228
+ message="Tests failed",
229
+ details=details,
230
+ )
@@ -0,0 +1,257 @@
1
+ """Type checking with configurable backends.
2
+
3
+ This module provides the TypingCheck class which runs static type
4
+ checking tools (mypy or pyright) on the package and reports any errors.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pycmdcheck.checks.base import BaseCheck
11
+ from pycmdcheck.constants import LONG_TIMEOUT, MAX_DETAIL_LINES
12
+ from pycmdcheck.package_layout import PackageLayout
13
+ from pycmdcheck.results import CheckResult, CheckStatus
14
+ from pycmdcheck.subprocess_runner import run_tool, sanitize_args, tool_available
15
+
16
+
17
+ class TypingCheck(BaseCheck):
18
+ """Run type checking tools on the package.
19
+
20
+ Executes a configurable static type checker to verify type
21
+ annotations. Supports mypy (default) and pyright.
22
+
23
+ Attributes:
24
+ name: The check identifier ("typing").
25
+ description: Human-readable description of this check.
26
+ SUPPORTED_TOOLS: List of supported type checking tools.
27
+
28
+ Configuration Options:
29
+ tool (str): Type checker to use ("mypy" or "pyright").
30
+ Defaults to "mypy".
31
+ strict (bool): Whether to enable strict mode. Defaults to False.
32
+ args (list[str]): Additional arguments to pass to the type checker.
33
+
34
+ Examples:
35
+ Run type checking with default settings (mypy):
36
+
37
+ >>> from pathlib import Path
38
+ >>> check = TypingCheck()
39
+ >>> result = check.run(Path("."), {"enabled": True})
40
+ >>> result.name
41
+ 'typing'
42
+
43
+ Configure to use pyright:
44
+
45
+ >>> config = {"enabled": True, "tool": "pyright"}
46
+ >>> result = check.run(Path("."), config)
47
+
48
+ Enable strict mode:
49
+
50
+ >>> config = {"enabled": True, "strict": True}
51
+ >>> result = check.run(Path("."), config)
52
+ """
53
+
54
+ name = "typing"
55
+ description = "Run type checking (mypy/pyright)"
56
+
57
+ SUPPORTED_TOOLS = ["mypy", "pyright"]
58
+
59
+ def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
60
+ """Run type checking on the package.
61
+
62
+ Executes the configured type checker and collects any errors found.
63
+ Errors are reported in the result details (limited to first 10).
64
+
65
+ Args:
66
+ package_path: Path to the package directory to check.
67
+ config: Configuration dictionary with options:
68
+
69
+ - tool (str): "mypy" or "pyright" (default: "mypy")
70
+ - strict (bool): Enable strict mode (default: False)
71
+ - args (list[str]): Additional arguments for the checker
72
+
73
+ Returns:
74
+ A CheckResult with:
75
+
76
+ - OK status if no type errors found
77
+ - ERROR status if type errors found or checker fails
78
+ - SKIPPED status if the type checker is not installed
79
+ """
80
+ tool = config.get("tool", "mypy")
81
+
82
+ if tool not in self.SUPPORTED_TOOLS:
83
+ return CheckResult(
84
+ name=self.name,
85
+ status=CheckStatus.ERROR,
86
+ message=f"Unsupported type checker: {tool}",
87
+ details=[f"Supported tools: {', '.join(self.SUPPORTED_TOOLS)}"],
88
+ )
89
+
90
+ preamble = [f"Using type checker: {tool}"]
91
+
92
+ if tool == "mypy":
93
+ return self._run_mypy(package_path, config, preamble)
94
+ else: # pyright
95
+ return self._run_pyright(package_path, config, preamble)
96
+
97
+ def _run_mypy(
98
+ self, package_path: Path, config: dict[str, Any], preamble: list[str]
99
+ ) -> CheckResult:
100
+ """Run mypy type checker."""
101
+ if not tool_available("mypy"):
102
+ return CheckResult(
103
+ name=self.name,
104
+ status=CheckStatus.SKIPPED,
105
+ message="mypy not installed",
106
+ details=["Install mypy: pip install mypy"],
107
+ )
108
+
109
+ # Target the discovered package(s)/module(s) rather than ".", which on
110
+ # flat/single-module layouts makes mypy scan tests/build/etc. and emit
111
+ # spurious "Duplicate module" discovery failures. mypy auto-detects the
112
+ # base directory by walking up past __init__.py, so module names stay
113
+ # correct (we deliberately do NOT pass --explicit-package-bases, which
114
+ # would mis-root packages relative to the cwd).
115
+ layout = PackageLayout(package_path)
116
+ targets = [str(p) for p in layout.package_dirs]
117
+ targets += [str(m) for m in layout.top_level_modules()]
118
+ if not targets:
119
+ src_dir = package_path / "src"
120
+ targets = [str(src_dir) if src_dir.is_dir() else "."]
121
+
122
+ args = sanitize_args(config.get("args", []))
123
+ strict = config.get("strict", False)
124
+
125
+ cmd = ["mypy", "--namespace-packages", *targets]
126
+ if strict:
127
+ cmd.append("--strict")
128
+ cmd.extend(args)
129
+
130
+ result = run_tool(cmd, cwd=package_path, timeout=LONG_TIMEOUT)
131
+
132
+ if result.timed_out:
133
+ return CheckResult(
134
+ name=self.name,
135
+ status=CheckStatus.ERROR,
136
+ message="Type checking timed out",
137
+ details=[f"Type checking took longer than {LONG_TIMEOUT} seconds"],
138
+ )
139
+
140
+ if result.error is not None:
141
+ return CheckResult(
142
+ name=self.name,
143
+ status=CheckStatus.ERROR,
144
+ message="Failed to run mypy",
145
+ details=[result.error],
146
+ )
147
+
148
+ stdout = result.stdout.strip()
149
+
150
+ if result.success:
151
+ # Check for "Success" message
152
+ if "Success" in stdout or not stdout:
153
+ return CheckResult(
154
+ name=self.name,
155
+ status=CheckStatus.OK,
156
+ message="No type errors found",
157
+ details=list(preamble),
158
+ )
159
+
160
+ # mypy exit code 2 is a fatal/discovery/config error (e.g. duplicate
161
+ # modules, missing config) — not a type-error finding.
162
+ if result.returncode == 2:
163
+ detail_lines = (result.output or "").strip().splitlines()
164
+ return CheckResult(
165
+ name=self.name,
166
+ status=CheckStatus.WARNING,
167
+ message="mypy could not analyze the package (configuration/layout)",
168
+ details=[*preamble, *detail_lines[:MAX_DETAIL_LINES]],
169
+ )
170
+
171
+ # Parse mypy output for errors
172
+ errors = []
173
+ for line in stdout.split("\n"):
174
+ if ": error:" in line:
175
+ errors.append(line.strip())
176
+
177
+ if not errors and "error:" in stdout:
178
+ errors = [line.strip() for line in stdout.split("\n") if line.strip()]
179
+
180
+ details = [*preamble, *errors[:MAX_DETAIL_LINES]]
181
+
182
+ if len(errors) > MAX_DETAIL_LINES:
183
+ details.append(f"... and {len(errors) - MAX_DETAIL_LINES} more errors")
184
+
185
+ error_count = len(errors) if errors else "some"
186
+ return CheckResult(
187
+ name=self.name,
188
+ status=CheckStatus.ERROR,
189
+ message=f"Found {error_count} type error(s)",
190
+ details=details,
191
+ )
192
+
193
+ def _run_pyright(
194
+ self, package_path: Path, config: dict[str, Any], preamble: list[str]
195
+ ) -> CheckResult:
196
+ """Run pyright type checker."""
197
+ if not tool_available("pyright"):
198
+ return CheckResult(
199
+ name=self.name,
200
+ status=CheckStatus.SKIPPED,
201
+ message="pyright not installed",
202
+ details=["Install pyright: pip install pyright"],
203
+ )
204
+
205
+ args = sanitize_args(config.get("args", []))
206
+ strict = config.get("strict", False)
207
+
208
+ cmd = ["pyright"]
209
+ if strict:
210
+ cmd.append("--strict")
211
+ cmd.extend(args)
212
+
213
+ result = run_tool(cmd, cwd=package_path, timeout=LONG_TIMEOUT)
214
+
215
+ if result.timed_out:
216
+ return CheckResult(
217
+ name=self.name,
218
+ status=CheckStatus.ERROR,
219
+ message="Type checking timed out",
220
+ details=[f"Type checking took longer than {LONG_TIMEOUT} seconds"],
221
+ )
222
+
223
+ if result.error is not None:
224
+ return CheckResult(
225
+ name=self.name,
226
+ status=CheckStatus.ERROR,
227
+ message="Failed to run pyright",
228
+ details=[result.error],
229
+ )
230
+
231
+ if result.success:
232
+ return CheckResult(
233
+ name=self.name,
234
+ status=CheckStatus.OK,
235
+ message="No type errors found",
236
+ details=list(preamble),
237
+ )
238
+
239
+ # Parse pyright output
240
+ stdout = result.stdout.strip()
241
+ errors = []
242
+ for line in stdout.split("\n"):
243
+ if " - error:" in line or " - warning:" in line:
244
+ errors.append(line.strip())
245
+
246
+ details = [*preamble, *errors[:MAX_DETAIL_LINES]]
247
+
248
+ if len(errors) > MAX_DETAIL_LINES:
249
+ details.append(f"... and {len(errors) - MAX_DETAIL_LINES} more errors")
250
+
251
+ error_count = len(errors) if errors else "some"
252
+ return CheckResult(
253
+ name=self.name,
254
+ status=CheckStatus.ERROR,
255
+ message=f"Found {error_count} type error(s)",
256
+ details=details,
257
+ )
@@ -0,0 +1,124 @@
1
+ """URL reachability check.
2
+
3
+ Validates that URLs declared in package metadata are reachable.
4
+ """
5
+
6
+ import logging
7
+ import urllib.error
8
+ import urllib.request
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from pycmdcheck.checks.base import BaseCheck
13
+ from pycmdcheck.constants import MAX_DETAIL_LINES, MAX_URL_CHECKS, URL_TIMEOUT
14
+ from pycmdcheck.pyproject_reader import read_pyproject
15
+ from pycmdcheck.results import CheckResult, CheckStatus
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class URLsCheck(BaseCheck):
21
+ """Check that project URLs in metadata are reachable.
22
+
23
+ Extracts URLs from ``[project.urls]`` in pyproject.toml and
24
+ validates each with an HTTP HEAD request.
25
+
26
+ Attributes:
27
+ name: The check identifier ("urls").
28
+ description: Human-readable description of this check.
29
+ """
30
+
31
+ name = "urls"
32
+ description = "Validate project URLs are reachable"
33
+
34
+ def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
35
+ """Check URL reachability for project metadata URLs.
36
+
37
+ Args:
38
+ package_path: Path to the package directory to check.
39
+ config: Configuration dictionary for this check.
40
+ Supports ``timeout`` (int) to override URL_TIMEOUT.
41
+
42
+ Returns:
43
+ A CheckResult with:
44
+
45
+ - OK status if all URLs are reachable
46
+ - WARNING status if some URLs are unreachable
47
+ - NOTE status if no URLs found in metadata
48
+ """
49
+ details: list[str] = []
50
+ timeout = config.get("timeout", URL_TIMEOUT)
51
+
52
+ pyproject = read_pyproject(package_path)
53
+ if pyproject is None:
54
+ return CheckResult(
55
+ name=self.name,
56
+ status=CheckStatus.NOTE,
57
+ message="No pyproject.toml found",
58
+ details=["Cannot check URLs without pyproject.toml"],
59
+ )
60
+
61
+ project = pyproject.get("project", {})
62
+ urls = project.get("urls", {})
63
+
64
+ if not urls:
65
+ return CheckResult(
66
+ name=self.name,
67
+ status=CheckStatus.NOTE,
68
+ message="No project URLs defined",
69
+ details=[
70
+ "Add [project.urls] to pyproject.toml",
71
+ 'Example: Homepage = "https://github.com/user/project"',
72
+ ],
73
+ )
74
+
75
+ unreachable: list[str] = []
76
+ checked = 0
77
+
78
+ for label, url in list(urls.items())[:MAX_URL_CHECKS]:
79
+ checked += 1
80
+ reachable, error = self._check_url(url, timeout)
81
+ if reachable:
82
+ details.append(f"{label}: {url} (OK)")
83
+ else:
84
+ unreachable.append(label)
85
+ details.append(f"{label}: {url} (FAILED: {error})")
86
+
87
+ if unreachable:
88
+ return CheckResult(
89
+ name=self.name,
90
+ status=CheckStatus.WARNING,
91
+ message=(f"{len(unreachable)} of {checked} URL(s) unreachable"),
92
+ details=details[:MAX_DETAIL_LINES],
93
+ )
94
+
95
+ return CheckResult(
96
+ name=self.name,
97
+ status=CheckStatus.OK,
98
+ message=f"All {checked} project URL(s) reachable",
99
+ details=details[:MAX_DETAIL_LINES],
100
+ )
101
+
102
+ def _check_url(self, url: str, timeout: int) -> tuple[bool, str | None]:
103
+ """Check if a URL is reachable via HTTP HEAD then GET.
104
+
105
+ Returns:
106
+ A tuple of (reachable, error_message).
107
+ """
108
+ for method in ("HEAD", "GET"):
109
+ try:
110
+ req = urllib.request.Request(url, method=method)
111
+ req.add_header("User-Agent", "pycmdcheck/0.1")
112
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
113
+ if resp.status < 400:
114
+ return True, None
115
+ except urllib.error.HTTPError as e:
116
+ if method == "HEAD" and e.code == 405:
117
+ continue # Try GET instead
118
+ return False, f"HTTP {e.code}"
119
+ except urllib.error.URLError as e:
120
+ return False, str(e.reason)
121
+ except (OSError, ValueError) as e:
122
+ return False, str(e)
123
+
124
+ return False, "All methods failed"