libyear-multi 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shreyas Dhakal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.4
2
+ Name: libyear-multi
3
+ Version: 0.1.0
4
+ Summary: Language-agnostic libyear: dependency staleness scoring across PyPI, npm, crates.io, RubyGems and more.
5
+ Author: Shreyas Dhakal
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8.0; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # libyear-multi
18
+
19
+ ![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)
20
+
21
+ `libyear-multi` measures dependency staleness across the package managers used
22
+ by a project. It detects supported manifests, looks up release dates, and
23
+ reports a single score that can be used to prioritise maintenance work.
24
+
25
+ The [libyear](https://libyear.com/) metric measures dependency age in calendar
26
+ time rather than semantic-version distance. A package released three years ago
27
+ whose latest release shipped yesterday represents approximately three libyears
28
+ of staleness.
29
+
30
+ ## Highlights
31
+
32
+ - Scans multiple ecosystems in one repository, including monorepos.
33
+ - Reports total, average, median, and maximum libyears.
34
+ - Classifies results into low, moderate, high, and severe risk bands.
35
+ - Provides both human-readable and JSON output.
36
+ - Caches registry responses locally to reduce repeated network requests.
37
+
38
+ ## Supported Ecosystems
39
+
40
+ | Ecosystem | Manifest file | Registry |
41
+ | --- | --- | --- |
42
+ | Python | `requirements.txt` with pinned `==` versions | PyPI |
43
+ | Node.js | `package.json` | npm registry |
44
+ | Rust | `Cargo.lock` | crates.io |
45
+ | Ruby | `Gemfile.lock` | RubyGems |
46
+
47
+ Additional adapters are welcome. See [Adding an ecosystem](#adding-an-ecosystem).
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ python -m pip install -e .
53
+ ```
54
+
55
+ The package is not published to PyPI yet. Install it from a clone or use the
56
+ `libyear_multi/` package directly.
57
+
58
+ ## Usage
59
+
60
+ ### Command line
61
+
62
+ ```bash
63
+ python -m libyear_multi.cli /path/to/project
64
+ ```
65
+
66
+ Example output:
67
+
68
+ ```text
69
+ 14 dependencies scanned: 22.4 total libyears of staleness (avg 1.6 yrs/package, 21% severely outdated).
70
+ Risk band: high
71
+
72
+ Most outdated dependencies:
73
+ [npm] left-pad: 1.1.0 -> 1.3.0 (4.2 libyears)
74
+ [pypi] requests: 2.20.0 -> 2.32.3 (3.8 libyears)
75
+ ```
76
+
77
+ Use JSON output for scripts and CI integrations:
78
+
79
+ ```bash
80
+ python -m libyear_multi.cli /path/to/project --json
81
+ ```
82
+
83
+ The number of concurrent registry lookups can be configured with
84
+ `--max-workers`.
85
+
86
+ ### Python API
87
+
88
+ ```python
89
+ from libyear_multi import LibyearScanner, score_dependency_age, summary_line
90
+
91
+ scanner = LibyearScanner()
92
+ dependencies = scanner.scan("/path/to/project")
93
+
94
+ score = score_dependency_age(dependencies)
95
+ print(summary_line(score))
96
+ print(score.to_risk_band())
97
+
98
+ for dependency in dependencies:
99
+ print(dependency.name, dependency.ecosystem, dependency.libyears)
100
+ ```
101
+
102
+ ## How It Works
103
+
104
+ 1. Each adapter checks whether its manifest exists in the target directory.
105
+ 2. Detected adapters parse the manifest into package and installed-version pairs.
106
+ 3. Registry APIs provide the latest version and release dates.
107
+ 4. Staleness is calculated as `(latest_date - installed_date).days / 365.25`.
108
+ 5. Individual results are aggregated into a project score.
109
+
110
+ Release dates are cached permanently in
111
+ `~/.cache/libyear-multi/cache.sqlite3`. Latest-version lookups expire after 24
112
+ hours.
113
+
114
+ ## Development
115
+
116
+ Clone the repository and install the development dependencies:
117
+
118
+ ```bash
119
+ git clone https://github.com/shreyasdhakal/libyear-multi.git
120
+ cd libyear-multi
121
+ python -m pip install -e ".[dev]"
122
+ ```
123
+
124
+ Run the test suite:
125
+
126
+ ```bash
127
+ python -m pytest
128
+ ```
129
+
130
+ Before opening a pull request, make sure tests pass and that changes are
131
+ covered by tests where practical. Network-dependent registry calls are not part
132
+ of the unit test suite.
133
+
134
+ ## Adding an Ecosystem
135
+
136
+ Subclass `EcosystemAdapter` in `libyear_multi/core.py` and implement its four
137
+ abstract methods:
138
+
139
+ ```python
140
+ from datetime import datetime
141
+
142
+ from libyear_multi.core import EcosystemAdapter
143
+
144
+
145
+ class MyAdapter(EcosystemAdapter):
146
+ name = "my_ecosystem"
147
+
148
+ def detect(self, project_path: str) -> bool: ...
149
+ def list_dependencies(self, project_path: str) -> list[tuple[str, str]]: ...
150
+ def get_latest_version(self, name: str) -> str | None: ...
151
+ def get_release_date(self, name: str, version: str) -> datetime | None: ...
152
+ ```
153
+
154
+ Register the adapter in `libyear_multi/adapters/__init__.py` and add parsing
155
+ tests in `tests/`. Good future candidates include Go, Maven, Composer, and
156
+ NuGet.
157
+
158
+ ## Known Limitations
159
+
160
+ - Python adapters currently expect pinned `==` versions.
161
+ - Rust and Ruby adapters read lockfiles, while npm currently reads `package.json`.
162
+ - Non-registry npm specifications such as Git URLs and workspace references are skipped.
163
+ - Transitive dependencies are only included when they appear in a parsed lockfile.
164
+ - Large repositories may encounter registry rate limits. The default worker limit is eight and responses are cached.
165
+ - The metric uses registry release dates and does not measure repository activity or abandonment.
166
+
167
+ ## Contributing
168
+
169
+ Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before
170
+ submitting a change. Bug reports, documentation improvements, new adapters, and
171
+ focused fixes are all useful.
172
+
173
+ ## Security
174
+
175
+ Please report suspected vulnerabilities privately by following the instructions
176
+ in [SECURITY.md](SECURITY.md). Do not disclose exploitable details in a public
177
+ issue.
178
+
179
+ ## License
180
+
181
+ This project is licensed under the MIT License. See [LICENSE](LICENSE).
182
+
183
+ Copyright (c) 2026 Shreyas Dhakal.
@@ -0,0 +1,167 @@
1
+ # libyear-multi
2
+
3
+ ![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)
4
+
5
+ `libyear-multi` measures dependency staleness across the package managers used
6
+ by a project. It detects supported manifests, looks up release dates, and
7
+ reports a single score that can be used to prioritise maintenance work.
8
+
9
+ The [libyear](https://libyear.com/) metric measures dependency age in calendar
10
+ time rather than semantic-version distance. A package released three years ago
11
+ whose latest release shipped yesterday represents approximately three libyears
12
+ of staleness.
13
+
14
+ ## Highlights
15
+
16
+ - Scans multiple ecosystems in one repository, including monorepos.
17
+ - Reports total, average, median, and maximum libyears.
18
+ - Classifies results into low, moderate, high, and severe risk bands.
19
+ - Provides both human-readable and JSON output.
20
+ - Caches registry responses locally to reduce repeated network requests.
21
+
22
+ ## Supported Ecosystems
23
+
24
+ | Ecosystem | Manifest file | Registry |
25
+ | --- | --- | --- |
26
+ | Python | `requirements.txt` with pinned `==` versions | PyPI |
27
+ | Node.js | `package.json` | npm registry |
28
+ | Rust | `Cargo.lock` | crates.io |
29
+ | Ruby | `Gemfile.lock` | RubyGems |
30
+
31
+ Additional adapters are welcome. See [Adding an ecosystem](#adding-an-ecosystem).
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ python -m pip install -e .
37
+ ```
38
+
39
+ The package is not published to PyPI yet. Install it from a clone or use the
40
+ `libyear_multi/` package directly.
41
+
42
+ ## Usage
43
+
44
+ ### Command line
45
+
46
+ ```bash
47
+ python -m libyear_multi.cli /path/to/project
48
+ ```
49
+
50
+ Example output:
51
+
52
+ ```text
53
+ 14 dependencies scanned: 22.4 total libyears of staleness (avg 1.6 yrs/package, 21% severely outdated).
54
+ Risk band: high
55
+
56
+ Most outdated dependencies:
57
+ [npm] left-pad: 1.1.0 -> 1.3.0 (4.2 libyears)
58
+ [pypi] requests: 2.20.0 -> 2.32.3 (3.8 libyears)
59
+ ```
60
+
61
+ Use JSON output for scripts and CI integrations:
62
+
63
+ ```bash
64
+ python -m libyear_multi.cli /path/to/project --json
65
+ ```
66
+
67
+ The number of concurrent registry lookups can be configured with
68
+ `--max-workers`.
69
+
70
+ ### Python API
71
+
72
+ ```python
73
+ from libyear_multi import LibyearScanner, score_dependency_age, summary_line
74
+
75
+ scanner = LibyearScanner()
76
+ dependencies = scanner.scan("/path/to/project")
77
+
78
+ score = score_dependency_age(dependencies)
79
+ print(summary_line(score))
80
+ print(score.to_risk_band())
81
+
82
+ for dependency in dependencies:
83
+ print(dependency.name, dependency.ecosystem, dependency.libyears)
84
+ ```
85
+
86
+ ## How It Works
87
+
88
+ 1. Each adapter checks whether its manifest exists in the target directory.
89
+ 2. Detected adapters parse the manifest into package and installed-version pairs.
90
+ 3. Registry APIs provide the latest version and release dates.
91
+ 4. Staleness is calculated as `(latest_date - installed_date).days / 365.25`.
92
+ 5. Individual results are aggregated into a project score.
93
+
94
+ Release dates are cached permanently in
95
+ `~/.cache/libyear-multi/cache.sqlite3`. Latest-version lookups expire after 24
96
+ hours.
97
+
98
+ ## Development
99
+
100
+ Clone the repository and install the development dependencies:
101
+
102
+ ```bash
103
+ git clone https://github.com/shreyasdhakal/libyear-multi.git
104
+ cd libyear-multi
105
+ python -m pip install -e ".[dev]"
106
+ ```
107
+
108
+ Run the test suite:
109
+
110
+ ```bash
111
+ python -m pytest
112
+ ```
113
+
114
+ Before opening a pull request, make sure tests pass and that changes are
115
+ covered by tests where practical. Network-dependent registry calls are not part
116
+ of the unit test suite.
117
+
118
+ ## Adding an Ecosystem
119
+
120
+ Subclass `EcosystemAdapter` in `libyear_multi/core.py` and implement its four
121
+ abstract methods:
122
+
123
+ ```python
124
+ from datetime import datetime
125
+
126
+ from libyear_multi.core import EcosystemAdapter
127
+
128
+
129
+ class MyAdapter(EcosystemAdapter):
130
+ name = "my_ecosystem"
131
+
132
+ def detect(self, project_path: str) -> bool: ...
133
+ def list_dependencies(self, project_path: str) -> list[tuple[str, str]]: ...
134
+ def get_latest_version(self, name: str) -> str | None: ...
135
+ def get_release_date(self, name: str, version: str) -> datetime | None: ...
136
+ ```
137
+
138
+ Register the adapter in `libyear_multi/adapters/__init__.py` and add parsing
139
+ tests in `tests/`. Good future candidates include Go, Maven, Composer, and
140
+ NuGet.
141
+
142
+ ## Known Limitations
143
+
144
+ - Python adapters currently expect pinned `==` versions.
145
+ - Rust and Ruby adapters read lockfiles, while npm currently reads `package.json`.
146
+ - Non-registry npm specifications such as Git URLs and workspace references are skipped.
147
+ - Transitive dependencies are only included when they appear in a parsed lockfile.
148
+ - Large repositories may encounter registry rate limits. The default worker limit is eight and responses are cached.
149
+ - The metric uses registry release dates and does not measure repository activity or abandonment.
150
+
151
+ ## Contributing
152
+
153
+ Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before
154
+ submitting a change. Bug reports, documentation improvements, new adapters, and
155
+ focused fixes are all useful.
156
+
157
+ ## Security
158
+
159
+ Please report suspected vulnerabilities privately by following the instructions
160
+ in [SECURITY.md](SECURITY.md). Do not disclose exploitable details in a public
161
+ issue.
162
+
163
+ ## License
164
+
165
+ This project is licensed under the MIT License. See [LICENSE](LICENSE).
166
+
167
+ Copyright (c) 2026 Shreyas Dhakal.
@@ -0,0 +1,16 @@
1
+ from .core import Dependency, EcosystemAdapter
2
+ from .scanner import LibyearScanner
3
+ from .scoring import DependencyAgeScore, score_dependency_age, summary_line
4
+ from .cache import Cache
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "Dependency",
10
+ "EcosystemAdapter",
11
+ "LibyearScanner",
12
+ "DependencyAgeScore",
13
+ "score_dependency_age",
14
+ "summary_line",
15
+ "Cache",
16
+ ]
@@ -0,0 +1,14 @@
1
+ from .pypi import PyPIAdapter
2
+ from .npm import NpmAdapter
3
+ from .cargo import CargoAdapter
4
+ from .rubygems import RubyGemsAdapter
5
+
6
+ ALL_ADAPTERS = [PyPIAdapter, NpmAdapter, CargoAdapter, RubyGemsAdapter]
7
+
8
+ __all__ = [
9
+ "PyPIAdapter",
10
+ "NpmAdapter",
11
+ "CargoAdapter",
12
+ "RubyGemsAdapter",
13
+ "ALL_ADAPTERS",
14
+ ]
@@ -0,0 +1,51 @@
1
+ """crates.io adapter for Rust Cargo.lock files."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import urllib.request
7
+ from datetime import datetime
8
+
9
+ from ..core import EcosystemAdapter
10
+
11
+
12
+ class CargoAdapter(EcosystemAdapter):
13
+ name = "cargo"
14
+
15
+ def detect(self, project_path: str) -> bool:
16
+ return os.path.exists(os.path.join(project_path, "Cargo.lock"))
17
+
18
+ def list_dependencies(self, project_path: str) -> list[tuple[str, str]]:
19
+ path = os.path.join(project_path, "Cargo.lock")
20
+ deps = []
21
+ with open(path) as f:
22
+ content = f.read()
23
+ # Parse package blocks directly so the core package has no TOML runtime
24
+ # dependency. This is sufficient for the fields needed by the scanner.
25
+ for block in content.split("[[package]]")[1:]:
26
+ name_match = re.search(r'name\s*=\s*"([^"]+)"', block)
27
+ version_match = re.search(r'version\s*=\s*"([^"]+)"', block)
28
+ if name_match and version_match:
29
+ deps.append((name_match.group(1), version_match.group(1)))
30
+ return deps
31
+
32
+ def get_latest_version(self, name: str) -> str | None:
33
+ try:
34
+ url = f"https://crates.io/api/v1/crates/{name}"
35
+ req = urllib.request.Request(url, headers={"User-Agent": "libyear-multi"})
36
+ with urllib.request.urlopen(req, timeout=10) as r:
37
+ data = json.load(r)
38
+ return data["crate"]["max_stable_version"]
39
+ except Exception:
40
+ return None
41
+
42
+ def get_release_date(self, name: str, version: str) -> datetime | None:
43
+ try:
44
+ url = f"https://crates.io/api/v1/crates/{name}/{version}"
45
+ req = urllib.request.Request(url, headers={"User-Agent": "libyear-multi"})
46
+ with urllib.request.urlopen(req, timeout=10) as r:
47
+ data = json.load(r)
48
+ ts = data["version"]["created_at"]
49
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
50
+ except Exception:
51
+ return None
@@ -0,0 +1,50 @@
1
+ """npm adapter for Node.js package.json files."""
2
+
3
+ import json
4
+ import os
5
+ import urllib.request
6
+ from datetime import datetime
7
+
8
+ from ..core import EcosystemAdapter
9
+
10
+
11
+ class NpmAdapter(EcosystemAdapter):
12
+ name = "npm"
13
+
14
+ def detect(self, project_path: str) -> bool:
15
+ return os.path.exists(os.path.join(project_path, "package.json"))
16
+
17
+ def list_dependencies(self, project_path: str) -> list[tuple[str, str]]:
18
+ path = os.path.join(project_path, "package.json")
19
+ with open(path) as f:
20
+ pkg = json.load(f)
21
+ deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
22
+ cleaned = []
23
+ for name, version in deps.items():
24
+ v = version.lstrip("^~=v ").strip()
25
+ # Registry metadata cannot resolve Git, file, or workspace specs.
26
+ if any(c in v for c in ["/", ":", "*", "workspace"]):
27
+ continue
28
+ cleaned.append((name, v))
29
+ return cleaned
30
+
31
+ def get_latest_version(self, name: str) -> str | None:
32
+ try:
33
+ url = f"https://registry.npmjs.org/{name}"
34
+ with urllib.request.urlopen(url, timeout=10) as r:
35
+ data = json.load(r)
36
+ return data.get("dist-tags", {}).get("latest")
37
+ except Exception:
38
+ return None
39
+
40
+ def get_release_date(self, name: str, version: str) -> datetime | None:
41
+ try:
42
+ url = f"https://registry.npmjs.org/{name}"
43
+ with urllib.request.urlopen(url, timeout=10) as r:
44
+ data = json.load(r)
45
+ ts = data.get("time", {}).get(version)
46
+ if not ts:
47
+ return None
48
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
49
+ except Exception:
50
+ return None
@@ -0,0 +1,50 @@
1
+ """PyPI adapter for Python requirements.txt files."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import urllib.request
7
+ from datetime import datetime
8
+
9
+ from ..core import EcosystemAdapter
10
+
11
+
12
+ class PyPIAdapter(EcosystemAdapter):
13
+ name = "pypi"
14
+
15
+ def detect(self, project_path: str) -> bool:
16
+ return os.path.exists(os.path.join(project_path, "requirements.txt"))
17
+
18
+ def list_dependencies(self, project_path: str) -> list[tuple[str, str]]:
19
+ deps = []
20
+ path = os.path.join(project_path, "requirements.txt")
21
+ with open(path) as f:
22
+ for line in f:
23
+ line = line.strip()
24
+ if not line or line.startswith("#"):
25
+ continue
26
+ match = re.match(r"^([A-Za-z0-9_.\-]+)\s*==\s*([A-Za-z0-9_.\-]+)", line)
27
+ if match:
28
+ deps.append((match.group(1), match.group(2)))
29
+ return deps
30
+
31
+ def get_latest_version(self, name: str) -> str | None:
32
+ try:
33
+ with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/json", timeout=10) as r:
34
+ data = json.load(r)
35
+ return data["info"]["version"]
36
+ except Exception:
37
+ return None
38
+
39
+ def get_release_date(self, name: str, version: str) -> datetime | None:
40
+ try:
41
+ url = f"https://pypi.org/pypi/{name}/{version}/json"
42
+ with urllib.request.urlopen(url, timeout=10) as r:
43
+ data = json.load(r)
44
+ urls = data.get("urls") or []
45
+ if not urls:
46
+ return None
47
+ ts = urls[0]["upload_time_iso_8601"].replace("Z", "+00:00")
48
+ return datetime.fromisoformat(ts)
49
+ except Exception:
50
+ return None
@@ -0,0 +1,64 @@
1
+ """RubyGems adapter for Ruby Gemfile.lock files."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import urllib.request
7
+ from datetime import datetime
8
+
9
+ from ..core import EcosystemAdapter
10
+
11
+
12
+ class RubyGemsAdapter(EcosystemAdapter):
13
+ name = "rubygems"
14
+
15
+ def detect(self, project_path: str) -> bool:
16
+ return os.path.exists(os.path.join(project_path, "Gemfile.lock"))
17
+
18
+ def list_dependencies(self, project_path: str) -> list[tuple[str, str]]:
19
+ path = os.path.join(project_path, "Gemfile.lock")
20
+ deps = []
21
+ in_specs = False
22
+ with open(path) as f:
23
+ for line in f:
24
+ stripped = line.strip()
25
+ if stripped == "specs:":
26
+ in_specs = True
27
+ continue
28
+ if in_specs:
29
+ if not line.startswith(" ") or line.startswith(" "):
30
+ # Nested entries are transitive dependencies. The
31
+ # scanner currently reports only top-level gems.
32
+ if not re.match(r"^\s{4}\S", line):
33
+ continue
34
+ match = re.match(r"^\s{4}([A-Za-z0-9_.\-]+)\s+\(([^)]+)\)", line)
35
+ if match:
36
+ deps.append((match.group(1), match.group(2)))
37
+ elif line.strip() == "" or not line.startswith(" "):
38
+ in_specs = False
39
+ return deps
40
+
41
+ def get_latest_version(self, name: str) -> str | None:
42
+ try:
43
+ url = f"https://rubygems.org/api/v1/versions/{name}.json"
44
+ with urllib.request.urlopen(url, timeout=10) as r:
45
+ data = json.load(r)
46
+ for entry in data:
47
+ if not entry.get("prerelease"):
48
+ return entry["number"]
49
+ return data[0]["number"] if data else None
50
+ except Exception:
51
+ return None
52
+
53
+ def get_release_date(self, name: str, version: str) -> datetime | None:
54
+ try:
55
+ url = f"https://rubygems.org/api/v1/versions/{name}.json"
56
+ with urllib.request.urlopen(url, timeout=10) as r:
57
+ data = json.load(r)
58
+ for entry in data:
59
+ if entry["number"] == version:
60
+ ts = entry["created_at"]
61
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
62
+ return None
63
+ except Exception:
64
+ return None