fact-kit 0.3.0a1__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.
- fact_kit-0.3.0a1/LICENSE +21 -0
- fact_kit-0.3.0a1/PKG-INFO +97 -0
- fact_kit-0.3.0a1/README.md +81 -0
- fact_kit-0.3.0a1/pyproject.toml +35 -0
- fact_kit-0.3.0a1/pyproject.toml.orig +31 -0
- fact_kit-0.3.0a1/src/fact_kit/__init__.py +17 -0
- fact_kit-0.3.0a1/src/fact_kit/_errors.py +24 -0
- fact_kit-0.3.0a1/src/fact_kit/_read.py +113 -0
- fact_kit-0.3.0a1/src/fact_kit/_resolve.py +213 -0
- fact_kit-0.3.0a1/src/fact_kit/_select.py +247 -0
- fact_kit-0.3.0a1/src/fact_kit/_version.py +54 -0
- fact_kit-0.3.0a1/src/fact_kit/py.typed +0 -0
fact_kit-0.3.0a1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carlos Space
|
|
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,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fact-kit
|
|
3
|
+
Version: 0.3.0a1
|
|
4
|
+
Summary: Reads the fact packages built by fact-kit
|
|
5
|
+
Keywords: fact-kit,facts,semver
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Typing :: Typed
|
|
10
|
+
Requires-Dist: orjson>=3.10
|
|
11
|
+
Requires-Dist: semver>=3.0
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Project-URL: Repository, https://github.com/carlos-space/fact-kit
|
|
14
|
+
Project-URL: Issues, https://github.com/carlos-space/fact-kit/issues
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# fact-kit(Python)
|
|
18
|
+
|
|
19
|
+
讀取 fact-kit 建出來的 `-facts` 套件。行為規格是倉庫裡的 `conformance/README.md`,五種語言的讀取實作共用同一份樣本。
|
|
20
|
+
|
|
21
|
+
## 安裝
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
uv add fact-kit
|
|
25
|
+
# 或
|
|
26
|
+
pip install fact-kit
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
需要 Python 3.11 以上。依賴 `orjson`(讀 JSON)與 `semver`(比較版號)。PyPI 套件名是 `fact-kit`,匯入名是 `fact_kit`。
|
|
30
|
+
|
|
31
|
+
## 用法
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from fact_kit import FactReadError, read_facts
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
reader = read_facts("demo-facts/sdk", root="/path/to/hub", version="0.50.0", platform="claude-code")
|
|
38
|
+
except FactReadError as error:
|
|
39
|
+
print(error.code) # not-an-index、unsupported-index-format、not-found 或 invalid-version
|
|
40
|
+
raise
|
|
41
|
+
|
|
42
|
+
limit = reader.value("demo.limit") # 取 content["value"]
|
|
43
|
+
fact = reader.get("demo.limit") # 整筆 fact,帶 content、since,有的話還有 stability
|
|
44
|
+
for item in reader.facts: # 這個讀者看得到的所有 fact,順序同 index
|
|
45
|
+
print(item["id"], item["since"])
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`read_facts(spec, *, root=None, version=None, include=(), platform=None, os=None) -> FactReader`
|
|
49
|
+
|
|
50
|
+
| 參數 | 說明 |
|
|
51
|
+
| :--------- | :--------------------------------------------------------------------- |
|
|
52
|
+
| `spec` | `<套件>` 或 `<套件>/<路徑>`,套件名可以帶 scope |
|
|
53
|
+
| `root` | 從這個目錄開始找套件,預設是目前的工作目錄。工作目錄不固定時請明確傳入 |
|
|
54
|
+
| `version` | 讀者正在跑的版號,或 `"head"`。沒給時讀最新已發布的內容 |
|
|
55
|
+
| `include` | `head` 讀者額外要讀的 `pending` 標籤 |
|
|
56
|
+
| `platform` | 讀者的平台,例如 `"claude-code"` |
|
|
57
|
+
| `os` | `"macos"`、`"linux"` 或 `"windows"` |
|
|
58
|
+
|
|
59
|
+
`FactReader` 有 `package`、`version`(已安裝 fact 套件的版號)、`entry`、`describes`、`facts`、`has(id)`、`get(id)` 與 `value(id)`。`get` 找不到 id 時丟 `KeyError`,`value` 遇到沒有 `value` 的 content 時丟 `ValueError`。
|
|
60
|
+
|
|
61
|
+
只想篩選已經讀進來的 index 時,用 `select_facts(index, *, version=None, include=(), platform=None, os=None) -> list[dict]`。`compare_versions(a, b)` 與 `release_line(version)` 是版號比較與發布線的公開函式。
|
|
62
|
+
|
|
63
|
+
## 套件解析
|
|
64
|
+
|
|
65
|
+
照 Node 的 `require.resolve` 規則,以 `root` 為起點:
|
|
66
|
+
|
|
67
|
+
1. self-reference:最近的 `package.json` 名稱等於被讀的套件,而且有 `exports` 時,用它解析。
|
|
68
|
+
2. 往上層找第一個有 `node_modules/<套件>` 的目錄,並跟著符號連結。
|
|
69
|
+
3. `exports` 支援字串、陣列、條件物件(條件集合是 `node-addons`、`node`、`require`、`module-sync`、`default`,依寫的順序)、巢狀條件、`*` 萬用字元與 `null`。
|
|
70
|
+
4. 沒有 `exports` 時,退回檔案路徑 `<套件目錄>/<路徑>/index.json`。檔案不存在時,往上一層的 `node_modules` 繼續找。
|
|
71
|
+
|
|
72
|
+
已知差異:不讀 `NODE_PATH`,也不找全域資料夾。
|
|
73
|
+
|
|
74
|
+
## 錯誤代碼
|
|
75
|
+
|
|
76
|
+
所有錯誤都是 `FactReadError`(繼承 `Exception`),用 `.code` 判斷。訊息給人看,之後可能改。
|
|
77
|
+
|
|
78
|
+
| `.code` | 何時 |
|
|
79
|
+
| :------------------------- | :-------------------------------------------------------------------------------- |
|
|
80
|
+
| `not-an-index` | 檔案不是 JSON,頂層不是物件,`facts` 不是陣列,或 `package` 不是字串 |
|
|
81
|
+
| `unsupported-index-format` | `indexFormat` 的 major 不是 1,或不是 `<數字>.<數字>` 的字串 |
|
|
82
|
+
| `not-found` | 找不到套件,套件沒有 export 那個 `index.json`,檔案不存在,或 `spec` 的寫法不合格 |
|
|
83
|
+
| `invalid-version` | `version` 不是 `"head"` 也不是完整的 `X.Y.Z`,或 index 裡的 `since` 不是版號 |
|
|
84
|
+
|
|
85
|
+
## 和 JS 版的差異
|
|
86
|
+
|
|
87
|
+
- 不比對 `describes` 與已安裝產品的版本。`reader.describes` 會原樣回傳,但不會因為已安裝的產品比較新而丟錯,也不會用已安裝的版本當預設的 `version`。
|
|
88
|
+
- 不提供 `onRead`。
|
|
89
|
+
|
|
90
|
+
## 開發
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
cd py
|
|
94
|
+
uv run pytest
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
測試讀倉庫的 `conformance/`。位置不在往上找得到的地方時,設環境變數 `FACT_KIT_CONFORMANCE`。讀自己 facts 的測試用 `FACT_KIT_FACTS` 指到 `facts/` 目錄。
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# fact-kit(Python)
|
|
2
|
+
|
|
3
|
+
讀取 fact-kit 建出來的 `-facts` 套件。行為規格是倉庫裡的 `conformance/README.md`,五種語言的讀取實作共用同一份樣本。
|
|
4
|
+
|
|
5
|
+
## 安裝
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
uv add fact-kit
|
|
9
|
+
# 或
|
|
10
|
+
pip install fact-kit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
需要 Python 3.11 以上。依賴 `orjson`(讀 JSON)與 `semver`(比較版號)。PyPI 套件名是 `fact-kit`,匯入名是 `fact_kit`。
|
|
14
|
+
|
|
15
|
+
## 用法
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from fact_kit import FactReadError, read_facts
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
reader = read_facts("demo-facts/sdk", root="/path/to/hub", version="0.50.0", platform="claude-code")
|
|
22
|
+
except FactReadError as error:
|
|
23
|
+
print(error.code) # not-an-index、unsupported-index-format、not-found 或 invalid-version
|
|
24
|
+
raise
|
|
25
|
+
|
|
26
|
+
limit = reader.value("demo.limit") # 取 content["value"]
|
|
27
|
+
fact = reader.get("demo.limit") # 整筆 fact,帶 content、since,有的話還有 stability
|
|
28
|
+
for item in reader.facts: # 這個讀者看得到的所有 fact,順序同 index
|
|
29
|
+
print(item["id"], item["since"])
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`read_facts(spec, *, root=None, version=None, include=(), platform=None, os=None) -> FactReader`
|
|
33
|
+
|
|
34
|
+
| 參數 | 說明 |
|
|
35
|
+
| :--------- | :--------------------------------------------------------------------- |
|
|
36
|
+
| `spec` | `<套件>` 或 `<套件>/<路徑>`,套件名可以帶 scope |
|
|
37
|
+
| `root` | 從這個目錄開始找套件,預設是目前的工作目錄。工作目錄不固定時請明確傳入 |
|
|
38
|
+
| `version` | 讀者正在跑的版號,或 `"head"`。沒給時讀最新已發布的內容 |
|
|
39
|
+
| `include` | `head` 讀者額外要讀的 `pending` 標籤 |
|
|
40
|
+
| `platform` | 讀者的平台,例如 `"claude-code"` |
|
|
41
|
+
| `os` | `"macos"`、`"linux"` 或 `"windows"` |
|
|
42
|
+
|
|
43
|
+
`FactReader` 有 `package`、`version`(已安裝 fact 套件的版號)、`entry`、`describes`、`facts`、`has(id)`、`get(id)` 與 `value(id)`。`get` 找不到 id 時丟 `KeyError`,`value` 遇到沒有 `value` 的 content 時丟 `ValueError`。
|
|
44
|
+
|
|
45
|
+
只想篩選已經讀進來的 index 時,用 `select_facts(index, *, version=None, include=(), platform=None, os=None) -> list[dict]`。`compare_versions(a, b)` 與 `release_line(version)` 是版號比較與發布線的公開函式。
|
|
46
|
+
|
|
47
|
+
## 套件解析
|
|
48
|
+
|
|
49
|
+
照 Node 的 `require.resolve` 規則,以 `root` 為起點:
|
|
50
|
+
|
|
51
|
+
1. self-reference:最近的 `package.json` 名稱等於被讀的套件,而且有 `exports` 時,用它解析。
|
|
52
|
+
2. 往上層找第一個有 `node_modules/<套件>` 的目錄,並跟著符號連結。
|
|
53
|
+
3. `exports` 支援字串、陣列、條件物件(條件集合是 `node-addons`、`node`、`require`、`module-sync`、`default`,依寫的順序)、巢狀條件、`*` 萬用字元與 `null`。
|
|
54
|
+
4. 沒有 `exports` 時,退回檔案路徑 `<套件目錄>/<路徑>/index.json`。檔案不存在時,往上一層的 `node_modules` 繼續找。
|
|
55
|
+
|
|
56
|
+
已知差異:不讀 `NODE_PATH`,也不找全域資料夾。
|
|
57
|
+
|
|
58
|
+
## 錯誤代碼
|
|
59
|
+
|
|
60
|
+
所有錯誤都是 `FactReadError`(繼承 `Exception`),用 `.code` 判斷。訊息給人看,之後可能改。
|
|
61
|
+
|
|
62
|
+
| `.code` | 何時 |
|
|
63
|
+
| :------------------------- | :-------------------------------------------------------------------------------- |
|
|
64
|
+
| `not-an-index` | 檔案不是 JSON,頂層不是物件,`facts` 不是陣列,或 `package` 不是字串 |
|
|
65
|
+
| `unsupported-index-format` | `indexFormat` 的 major 不是 1,或不是 `<數字>.<數字>` 的字串 |
|
|
66
|
+
| `not-found` | 找不到套件,套件沒有 export 那個 `index.json`,檔案不存在,或 `spec` 的寫法不合格 |
|
|
67
|
+
| `invalid-version` | `version` 不是 `"head"` 也不是完整的 `X.Y.Z`,或 index 裡的 `since` 不是版號 |
|
|
68
|
+
|
|
69
|
+
## 和 JS 版的差異
|
|
70
|
+
|
|
71
|
+
- 不比對 `describes` 與已安裝產品的版本。`reader.describes` 會原樣回傳,但不會因為已安裝的產品比較新而丟錯,也不會用已安裝的版本當預設的 `version`。
|
|
72
|
+
- 不提供 `onRead`。
|
|
73
|
+
|
|
74
|
+
## 開發
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
cd py
|
|
78
|
+
uv run pytest
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
測試讀倉庫的 `conformance/`。位置不在往上找得到的地方時,設環境變數 `FACT_KIT_CONFORMANCE`。讀自己 facts 的測試用 `FACT_KIT_FACTS` 指到 `facts/` 目錄。
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fact-kit"
|
|
3
|
+
version = "0.3.0-alpha.1"
|
|
4
|
+
description = "Reads the fact packages built by fact-kit"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
keywords = [
|
|
9
|
+
"fact-kit",
|
|
10
|
+
"facts",
|
|
11
|
+
"semver",
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"orjson>=3.10",
|
|
16
|
+
"semver>=3.0",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/carlos-space/fact-kit"
|
|
25
|
+
Issues = "https://github.com/carlos-space/fact-kit/issues"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = ["pytest>=8"]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["uv_build>=0.12,<0.13"]
|
|
32
|
+
build-backend = "uv_build"
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fact-kit"
|
|
3
|
+
version = "0.3.0-alpha.1"
|
|
4
|
+
description = "Reads the fact packages built by fact-kit"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
keywords = ["fact-kit", "facts", "semver"]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"orjson>=3.10",
|
|
12
|
+
"semver>=3.0",
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Repository = "https://github.com/carlos-space/fact-kit"
|
|
21
|
+
Issues = "https://github.com/carlos-space/fact-kit/issues"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = ["pytest>=8"]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["uv_build>=0.12,<0.13"]
|
|
28
|
+
build-backend = "uv_build"
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Read the fact packages that fact-kit builds."""
|
|
2
|
+
|
|
3
|
+
from ._errors import ERROR_CODES, ErrorCode, FactReadError
|
|
4
|
+
from ._read import FactReader, read_facts
|
|
5
|
+
from ._select import select_facts
|
|
6
|
+
from ._version import compare_versions, release_line
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ERROR_CODES",
|
|
10
|
+
"ErrorCode",
|
|
11
|
+
"FactReadError",
|
|
12
|
+
"FactReader",
|
|
13
|
+
"compare_versions",
|
|
14
|
+
"read_facts",
|
|
15
|
+
"release_line",
|
|
16
|
+
"select_facts",
|
|
17
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""The error a reader raises."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final, Literal
|
|
6
|
+
|
|
7
|
+
ErrorCode = Literal["not-an-index", "unsupported-index-format", "not-found", "invalid-version"]
|
|
8
|
+
|
|
9
|
+
ERROR_CODES: Final[tuple[str, ...]] = (
|
|
10
|
+
"not-an-index",
|
|
11
|
+
"unsupported-index-format",
|
|
12
|
+
"not-found",
|
|
13
|
+
"invalid-version",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FactReadError(Exception):
|
|
18
|
+
"""A read failed. ``code`` is stable, the message is for people and can change."""
|
|
19
|
+
|
|
20
|
+
code: ErrorCode
|
|
21
|
+
|
|
22
|
+
def __init__(self, code: ErrorCode, message: str) -> None:
|
|
23
|
+
super().__init__(f"{message} ({code})")
|
|
24
|
+
self.code = code
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""``read_facts``: find an installed fact package, parse its index and select the facts of one reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os as _os
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import orjson
|
|
10
|
+
|
|
11
|
+
from ._errors import FactReadError
|
|
12
|
+
from ._resolve import find_index_file
|
|
13
|
+
from ._select import assert_index, select_facts
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _owning_version(file: str, name: str) -> str:
|
|
17
|
+
"""The ``package.json`` version of the package ``name`` that contains ``file``."""
|
|
18
|
+
directory = _os.path.dirname(file)
|
|
19
|
+
while True:
|
|
20
|
+
manifest = _os.path.join(directory, "package.json")
|
|
21
|
+
if _os.path.isfile(manifest):
|
|
22
|
+
try:
|
|
23
|
+
with open(manifest, "rb") as handle:
|
|
24
|
+
data = orjson.loads(handle.read())
|
|
25
|
+
except (OSError, orjson.JSONDecodeError):
|
|
26
|
+
data = None
|
|
27
|
+
if isinstance(data, dict) and data.get("name") == name:
|
|
28
|
+
version = data.get("version")
|
|
29
|
+
return version if isinstance(version, str) else ""
|
|
30
|
+
parent = _os.path.dirname(directory)
|
|
31
|
+
if parent == directory:
|
|
32
|
+
return ""
|
|
33
|
+
directory = parent
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FactReader:
|
|
37
|
+
"""The facts one reader sees, from one installed ``index.json``."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
package: str,
|
|
43
|
+
version: str,
|
|
44
|
+
entry: str,
|
|
45
|
+
describes: dict[str, str],
|
|
46
|
+
facts: list[dict[str, Any]],
|
|
47
|
+
) -> None:
|
|
48
|
+
self.package = package
|
|
49
|
+
"""Name of the fact package, from the index."""
|
|
50
|
+
self.version = version
|
|
51
|
+
"""Version of the installed fact package, from its ``package.json``."""
|
|
52
|
+
self.entry = entry
|
|
53
|
+
"""Sub-path of the entry inside the package."""
|
|
54
|
+
self.describes = describes
|
|
55
|
+
"""The component this entry describes and the version it covers."""
|
|
56
|
+
self.facts = facts
|
|
57
|
+
"""The facts this reader sees, each flattened to the row that applies."""
|
|
58
|
+
self._by_id = {f["id"]: f for f in facts}
|
|
59
|
+
|
|
60
|
+
def has(self, id: str) -> bool:
|
|
61
|
+
"""Whether a fact with this id exists."""
|
|
62
|
+
return id in self._by_id
|
|
63
|
+
|
|
64
|
+
def get(self, id: str) -> dict[str, Any]:
|
|
65
|
+
"""The fact with this id. Raises ``KeyError`` when there is none, so a typo fails loudly."""
|
|
66
|
+
try:
|
|
67
|
+
return self._by_id[id]
|
|
68
|
+
except KeyError:
|
|
69
|
+
raise KeyError(f'read_facts: no fact "{id}" in {self.package}') from None
|
|
70
|
+
|
|
71
|
+
def value(self, id: str) -> Any:
|
|
72
|
+
"""The ``content["value"]`` of the fact with this id. Raises ``ValueError`` without value content."""
|
|
73
|
+
content = self.get(id).get("content")
|
|
74
|
+
if not isinstance(content, dict) or "value" not in content:
|
|
75
|
+
raise ValueError(f'read_facts: fact "{id}" in {self.package} has no value content')
|
|
76
|
+
return content["value"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read_facts(
|
|
80
|
+
spec: str,
|
|
81
|
+
*,
|
|
82
|
+
root: str | _os.PathLike[str] | None = None,
|
|
83
|
+
version: str | None = None,
|
|
84
|
+
include: Iterable[str] = (),
|
|
85
|
+
platform: str | None = None,
|
|
86
|
+
os: str | None = None,
|
|
87
|
+
) -> FactReader:
|
|
88
|
+
"""Read the built ``index.json`` of ``<package>`` or ``<package>/<path>`` as installed under ``root``.
|
|
89
|
+
|
|
90
|
+
The package is found the way Node's ``require.resolve`` finds it from ``root`` (default: the current
|
|
91
|
+
directory) and must export ``./index.json`` or ``./<path>/index.json``. The index may be format 1.0 or 1.1.
|
|
92
|
+
``version``, ``include``, ``platform`` and ``os`` choose what the reader sees, see :func:`select_facts`.
|
|
93
|
+
Raises :class:`FactReadError`.
|
|
94
|
+
"""
|
|
95
|
+
base = _os.getcwd() if root is None else _os.fspath(root)
|
|
96
|
+
base = _os.path.abspath(base)
|
|
97
|
+
file, package_name, entry = find_index_file(spec, base)
|
|
98
|
+
try:
|
|
99
|
+
with open(file, "rb") as handle:
|
|
100
|
+
index = orjson.loads(handle.read())
|
|
101
|
+
except (OSError, orjson.JSONDecodeError) as error:
|
|
102
|
+
raise FactReadError("not-an-index", f"read_facts: {file} is not a fact index ({error})") from error
|
|
103
|
+
assert_index(index, f"read_facts: {file}")
|
|
104
|
+
facts = select_facts(index, version=version, include=include, platform=platform, os=os)
|
|
105
|
+
describes = index.get("describes")
|
|
106
|
+
entry_name = index.get("entry")
|
|
107
|
+
return FactReader(
|
|
108
|
+
package=index["package"],
|
|
109
|
+
version=_owning_version(file, package_name),
|
|
110
|
+
entry=entry_name if isinstance(entry_name, str) else entry,
|
|
111
|
+
describes=describes if isinstance(describes, dict) else {},
|
|
112
|
+
facts=facts,
|
|
113
|
+
)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Find an installed ``index.json`` the way Node's ``require.resolve`` does.
|
|
2
|
+
|
|
3
|
+
Covers self-reference, the ``node_modules`` walk, symlinked packages, and ``exports`` (strings, arrays,
|
|
4
|
+
condition objects, nested conditions, ``*`` patterns, ``null``). ``NODE_PATH`` and the global folders are not
|
|
5
|
+
searched.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import orjson
|
|
15
|
+
|
|
16
|
+
from ._errors import FactReadError
|
|
17
|
+
|
|
18
|
+
# The conditions of `require`, in the sense of Node. Their order in `exports` is what counts, not this one.
|
|
19
|
+
CONDITIONS = frozenset({"node-addons", "node", "require", "module-sync", "default"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _InvalidTarget(Exception):
|
|
23
|
+
"""An `exports` target that is not allowed. An array skips it, anything else gives up."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def split_spec(spec: str) -> tuple[str, str]:
|
|
27
|
+
"""Split ``@scope/name/path`` or ``name/path`` into the package name and the sub-path (``""`` if none)."""
|
|
28
|
+
parts = spec.split("/")
|
|
29
|
+
name_length = 2 if spec.startswith("@") else 1
|
|
30
|
+
if len(parts) < name_length or any(not p for p in parts):
|
|
31
|
+
raise FactReadError(
|
|
32
|
+
"not-found", f'read_facts: "{spec}" is not of the form "<package>" or "<package>/<path>"'
|
|
33
|
+
)
|
|
34
|
+
name = "/".join(parts[:name_length])
|
|
35
|
+
if name.startswith(".") or "\\" in name or "%" in name or any(p in (".", "..") for p in parts):
|
|
36
|
+
raise FactReadError("not-found", f'read_facts: "{spec}" is not a valid package spec')
|
|
37
|
+
return name, "/".join(parts[name_length:])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read_package_json(directory: str) -> dict[str, Any] | None:
|
|
41
|
+
"""The parsed package.json of a directory, None if there is none. Unreadable JSON is `not-found`."""
|
|
42
|
+
path = os.path.join(directory, "package.json")
|
|
43
|
+
if not os.path.isfile(path):
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
with open(path, "rb") as handle:
|
|
47
|
+
data = orjson.loads(handle.read())
|
|
48
|
+
except (OSError, orjson.JSONDecodeError) as error:
|
|
49
|
+
raise FactReadError("not-found", f"cannot read {path} ({error})") from error
|
|
50
|
+
return data if isinstance(data, dict) else {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _not_found(message: str) -> FactReadError:
|
|
54
|
+
return FactReadError("not-found", message)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_INVALID_SEGMENT = re.compile(r"(?:\.|%2e){1,2}|node_modules|%6e%6f%64%65_modules", re.IGNORECASE)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _check_target(text: str) -> None:
|
|
61
|
+
if not text.startswith("./"):
|
|
62
|
+
raise _InvalidTarget(text)
|
|
63
|
+
for segment in re.split(r"[\\/]", text[2:]):
|
|
64
|
+
if segment and _INVALID_SEGMENT.fullmatch(segment):
|
|
65
|
+
raise _InvalidTarget(text)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_UNDEFINED: Any = object()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_target(target: Any, match: str | None) -> Any:
|
|
72
|
+
"""A relative path inside the package, None for `null`, or `_UNDEFINED` for no match.
|
|
73
|
+
|
|
74
|
+
Raises `_InvalidTarget`.
|
|
75
|
+
"""
|
|
76
|
+
if isinstance(target, str):
|
|
77
|
+
_check_target(target)
|
|
78
|
+
if match is None:
|
|
79
|
+
return target
|
|
80
|
+
for segment in re.split(r"[\\/]", match):
|
|
81
|
+
if segment and _INVALID_SEGMENT.fullmatch(segment):
|
|
82
|
+
raise _InvalidTarget(target)
|
|
83
|
+
return target.replace("*", match)
|
|
84
|
+
if isinstance(target, list):
|
|
85
|
+
for item in target:
|
|
86
|
+
try:
|
|
87
|
+
found = _resolve_target(item, match)
|
|
88
|
+
except _InvalidTarget:
|
|
89
|
+
continue
|
|
90
|
+
if found is not _UNDEFINED and found is not None:
|
|
91
|
+
return found
|
|
92
|
+
return _UNDEFINED
|
|
93
|
+
if isinstance(target, dict):
|
|
94
|
+
if any(k.isdigit() for k in target):
|
|
95
|
+
raise _InvalidTarget("array index key")
|
|
96
|
+
for key, value in target.items():
|
|
97
|
+
if key in CONDITIONS:
|
|
98
|
+
found = _resolve_target(value, match)
|
|
99
|
+
if found is not _UNDEFINED:
|
|
100
|
+
return found
|
|
101
|
+
return _UNDEFINED
|
|
102
|
+
if target is None:
|
|
103
|
+
return None
|
|
104
|
+
raise _InvalidTarget(str(target))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _pattern_compare(a: str, b: str) -> int:
|
|
108
|
+
base_a = a.index("*") + 1 if "*" in a else len(a)
|
|
109
|
+
base_b = b.index("*") + 1 if "*" in b else len(b)
|
|
110
|
+
if base_a > base_b:
|
|
111
|
+
return -1
|
|
112
|
+
if base_b > base_a:
|
|
113
|
+
return 1
|
|
114
|
+
if "*" not in a:
|
|
115
|
+
return 1
|
|
116
|
+
if "*" not in b:
|
|
117
|
+
return -1
|
|
118
|
+
if len(a) > len(b):
|
|
119
|
+
return -1
|
|
120
|
+
if len(b) > len(a):
|
|
121
|
+
return 1
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _resolve_exports(exports: Any, subpath: str) -> Any:
|
|
126
|
+
"""Node's PACKAGE_EXPORTS_RESOLVE for a require. Returns a relative path, or None when nothing matches."""
|
|
127
|
+
if isinstance(exports, dict):
|
|
128
|
+
dotted = [k for k in exports if k.startswith(".")]
|
|
129
|
+
if dotted and len(dotted) != len(exports):
|
|
130
|
+
raise _InvalidTarget("exports mixes subpaths and conditions")
|
|
131
|
+
if not dotted:
|
|
132
|
+
exports = {".": exports}
|
|
133
|
+
else:
|
|
134
|
+
exports = {".": exports}
|
|
135
|
+
if subpath in exports and "*" not in subpath and not subpath.endswith("/"):
|
|
136
|
+
return _resolve_target(exports[subpath], None)
|
|
137
|
+
best = ""
|
|
138
|
+
best_match = ""
|
|
139
|
+
for key in exports:
|
|
140
|
+
star = key.find("*")
|
|
141
|
+
if star == -1 or key.count("*") != 1 or subpath.endswith("/"):
|
|
142
|
+
continue
|
|
143
|
+
prefix, trailer = key[:star], key[star + 1 :]
|
|
144
|
+
if (
|
|
145
|
+
subpath.startswith(prefix)
|
|
146
|
+
and len(subpath) >= len(key)
|
|
147
|
+
and subpath.endswith(trailer)
|
|
148
|
+
and _pattern_compare(best, key) == 1
|
|
149
|
+
):
|
|
150
|
+
best = key
|
|
151
|
+
best_match = subpath[len(prefix) : len(subpath) - len(trailer)]
|
|
152
|
+
if best:
|
|
153
|
+
return _resolve_target(exports[best], best_match)
|
|
154
|
+
return _UNDEFINED
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _file_in(package_dir: str, relative: str) -> str:
|
|
158
|
+
path = os.path.normpath(os.path.join(package_dir, relative))
|
|
159
|
+
if not os.path.isfile(path):
|
|
160
|
+
raise _not_found(f"{path} does not exist")
|
|
161
|
+
return path
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resolve_in_package(package_dir: str, manifest: dict[str, Any] | None, entry: str, label: str) -> str:
|
|
165
|
+
subpath = f"./{entry}/index.json" if entry else "./index.json"
|
|
166
|
+
exports = manifest.get("exports") if manifest else None
|
|
167
|
+
if exports is None:
|
|
168
|
+
return _file_in(package_dir, subpath)
|
|
169
|
+
try:
|
|
170
|
+
relative = _resolve_exports(exports, subpath)
|
|
171
|
+
except _InvalidTarget as error:
|
|
172
|
+
raise _not_found(f'{label}: "{subpath}" has an invalid export ({error})') from error
|
|
173
|
+
if relative is _UNDEFINED or relative is None:
|
|
174
|
+
raise _not_found(f'{label}: the package does not export "{subpath}"')
|
|
175
|
+
return _file_in(package_dir, relative)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def find_index_file(spec: str, root: str) -> tuple[str, str, str]:
|
|
179
|
+
"""Locate ``<package>[/<path>]/index.json`` from ``root``. Returns (file, package name, entry)."""
|
|
180
|
+
name, entry = split_spec(spec)
|
|
181
|
+
label = f"read_facts({spec!r})"
|
|
182
|
+
# 1. Self-reference: the nearest package.json, when it is this package and has `exports`.
|
|
183
|
+
directory = root
|
|
184
|
+
while True:
|
|
185
|
+
manifest = _read_package_json(directory)
|
|
186
|
+
if manifest is not None:
|
|
187
|
+
if manifest.get("name") == name and manifest.get("exports") is not None:
|
|
188
|
+
return _resolve_in_package(directory, manifest, entry, label), name, entry
|
|
189
|
+
break
|
|
190
|
+
parent = os.path.dirname(directory)
|
|
191
|
+
if parent == directory:
|
|
192
|
+
break
|
|
193
|
+
directory = parent
|
|
194
|
+
# 2 and 3. The nearest node_modules/<name>, following symlinks.
|
|
195
|
+
directory = root
|
|
196
|
+
while True:
|
|
197
|
+
if os.path.basename(directory) != "node_modules":
|
|
198
|
+
candidate = os.path.join(directory, "node_modules", name)
|
|
199
|
+
if os.path.isdir(candidate):
|
|
200
|
+
package_dir = os.path.realpath(candidate)
|
|
201
|
+
manifest = _read_package_json(package_dir)
|
|
202
|
+
if manifest is not None and manifest.get("exports") is not None:
|
|
203
|
+
return _resolve_in_package(package_dir, manifest, entry, label), name, entry
|
|
204
|
+
# 4. Without `exports` the path is a file path. When the file is not there, Node goes on to
|
|
205
|
+
# the next node_modules up.
|
|
206
|
+
subpath = f"{entry}/index.json" if entry else "index.json"
|
|
207
|
+
if os.path.isfile(os.path.join(package_dir, *subpath.split("/"))):
|
|
208
|
+
return _resolve_in_package(package_dir, manifest, entry, label), name, entry
|
|
209
|
+
parent = os.path.dirname(directory)
|
|
210
|
+
if parent == directory:
|
|
211
|
+
break
|
|
212
|
+
directory = parent
|
|
213
|
+
raise _not_found(f"{label}: cannot find {name} from {root}. Install it there.")
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Choose the facts one reader sees from a parsed ``index.json``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import semver
|
|
12
|
+
|
|
13
|
+
from ._errors import FactReadError
|
|
14
|
+
from ._version import compare_lines, compare_semver, must_parse, parse_version
|
|
15
|
+
|
|
16
|
+
NO_SINCE = "0.0.0"
|
|
17
|
+
_MISSING: Any = object()
|
|
18
|
+
|
|
19
|
+
# A bound is (rank, version): rank 0 is -inf, 1 is a version, 2 is +inf ("next").
|
|
20
|
+
_Bound = tuple[int, semver.Version | None]
|
|
21
|
+
_MIN: _Bound = (0, None)
|
|
22
|
+
_MAX: _Bound = (2, None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class _Reader:
|
|
27
|
+
kind: str # "head", "latest" or "version"
|
|
28
|
+
v: semver.Version | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _truthy(x: Any) -> bool:
|
|
32
|
+
"""JavaScript truthiness for a JSON value, so ``[]`` and ``{}`` count as set."""
|
|
33
|
+
return not (x is None or x is False or x == "" or (isinstance(x, (int, float)) and x == 0))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _compare_bounds(a: _Bound, b: _Bound) -> int:
|
|
37
|
+
if a[0] != b[0]:
|
|
38
|
+
return a[0] - b[0]
|
|
39
|
+
if a[0] == 1:
|
|
40
|
+
assert a[1] is not None and b[1] is not None
|
|
41
|
+
return compare_semver(a[1], b[1])
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _rows_of(fact: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
46
|
+
"""The rows of a fact. Without ``variants`` the whole fact is one row that starts at ``when.since``."""
|
|
47
|
+
variants = fact.get("variants")
|
|
48
|
+
if isinstance(variants, list):
|
|
49
|
+
rows = []
|
|
50
|
+
for v in variants:
|
|
51
|
+
if not isinstance(v, dict):
|
|
52
|
+
continue
|
|
53
|
+
row: dict[str, Any] = {}
|
|
54
|
+
for key in ("since", "content", "pending", "stability", "platforms", "os"):
|
|
55
|
+
if key in v:
|
|
56
|
+
row[key] = v[key]
|
|
57
|
+
rows.append(row)
|
|
58
|
+
return rows
|
|
59
|
+
when = fact.get("when")
|
|
60
|
+
when = when if isinstance(when, dict) else {}
|
|
61
|
+
row = {}
|
|
62
|
+
if "since" in when:
|
|
63
|
+
row["since"] = when["since"]
|
|
64
|
+
if "content" in fact:
|
|
65
|
+
row["content"] = fact["content"]
|
|
66
|
+
for key in ("platforms", "os"):
|
|
67
|
+
if key in when:
|
|
68
|
+
row[key] = when[key]
|
|
69
|
+
return [row]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _bound_of(since: Any, reader: _Reader, fact_id: str) -> _Bound | None:
|
|
73
|
+
if since is _MISSING:
|
|
74
|
+
return _MIN
|
|
75
|
+
what = f"fact {fact_id}: since"
|
|
76
|
+
if since == "next" and isinstance(since, str):
|
|
77
|
+
return _MAX if reader.kind == "head" else None
|
|
78
|
+
if isinstance(since, str):
|
|
79
|
+
s = must_parse(since, what)
|
|
80
|
+
if reader.kind == "version":
|
|
81
|
+
assert reader.v is not None
|
|
82
|
+
return (1, s) if compare_semver(reader.v, s) >= 0 else None
|
|
83
|
+
return (1, s)
|
|
84
|
+
if not isinstance(since, list):
|
|
85
|
+
raise FactReadError("invalid-version", f"{what} must be a string or an array")
|
|
86
|
+
has_next = any(isinstance(s, str) and s == "next" for s in since)
|
|
87
|
+
versions = [must_parse(s, what) for s in since if not (isinstance(s, str) and s == "next")]
|
|
88
|
+
if not versions:
|
|
89
|
+
return _MAX if reader.kind == "head" else None
|
|
90
|
+
newest = versions[0]
|
|
91
|
+
for s in versions[1:]:
|
|
92
|
+
if compare_semver(s, newest) > 0:
|
|
93
|
+
newest = s
|
|
94
|
+
if reader.kind == "version":
|
|
95
|
+
assert reader.v is not None
|
|
96
|
+
same = next((s for s in versions if compare_lines(s, reader.v) == 0), None)
|
|
97
|
+
if same is not None:
|
|
98
|
+
return (1, same) if compare_semver(reader.v, same) >= 0 else None
|
|
99
|
+
# No version on the reader's line: the row applies once the reader's line is newer than all of them.
|
|
100
|
+
return (1, newest) if all(compare_lines(reader.v, s) > 0 for s in versions) else None
|
|
101
|
+
return _MAX if reader.kind == "head" and has_next else (1, newest)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _specificity(row: Mapping[str, Any]) -> int:
|
|
105
|
+
return (1 if _truthy(row.get("platforms")) else 0) + (1 if _truthy(row.get("os")) else 0)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _contains(listed: Any, value: Any) -> bool:
|
|
109
|
+
if isinstance(listed, (list, str)):
|
|
110
|
+
return value in listed
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _canonical(row: Mapping[str, Any]) -> str:
|
|
115
|
+
"""The row as JSON with sorted keys and no whitespace, the text that breaks a tie."""
|
|
116
|
+
return json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _utf16(text: str) -> bytes:
|
|
120
|
+
return text.encode("utf-16-be", "surrogatepass")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _choose_row(
|
|
124
|
+
rows: list[dict[str, Any]],
|
|
125
|
+
reader: _Reader,
|
|
126
|
+
include: Iterable[str],
|
|
127
|
+
platform: str | None,
|
|
128
|
+
os_name: str | None,
|
|
129
|
+
fact_id: str,
|
|
130
|
+
) -> dict[str, Any] | None:
|
|
131
|
+
best: tuple[dict[str, Any], _Bound] | None = None
|
|
132
|
+
for row in rows:
|
|
133
|
+
if "pending" in row and not (reader.kind == "head" and _contains(list(include), row["pending"])):
|
|
134
|
+
continue
|
|
135
|
+
if _truthy(row.get("platforms")) and (platform is None or not _contains(row["platforms"], platform)):
|
|
136
|
+
continue
|
|
137
|
+
if _truthy(row.get("os")) and (os_name is None or not _contains(row["os"], os_name)):
|
|
138
|
+
continue
|
|
139
|
+
bound = _bound_of(row.get("since", _MISSING), reader, fact_id)
|
|
140
|
+
if bound is None:
|
|
141
|
+
continue
|
|
142
|
+
if best is None:
|
|
143
|
+
best = (row, bound)
|
|
144
|
+
continue
|
|
145
|
+
order = _compare_bounds(bound, best[1]) or _specificity(row) - _specificity(best[0])
|
|
146
|
+
# Equal rows are an author mistake that `check` reports. The canonical text keeps the result stable.
|
|
147
|
+
if order > 0 or (order == 0 and _utf16(_canonical(row)) < _utf16(_canonical(best[0]))):
|
|
148
|
+
best = (row, bound)
|
|
149
|
+
return best[0] if best else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def assert_index(index: Any, label: str = "the index") -> list[Any]:
|
|
153
|
+
"""Check the top of an index and return its facts. Raises ``not-an-index`` or ``unsupported-index-format``."""
|
|
154
|
+
if not isinstance(index, dict):
|
|
155
|
+
raise FactReadError("not-an-index", f"{label} is not a fact index")
|
|
156
|
+
fmt = index["indexFormat"] if "indexFormat" in index else "1.0"
|
|
157
|
+
m = re.fullmatch(r"([0-9]+)\.[0-9]+", fmt, re.ASCII) if isinstance(fmt, str) else None
|
|
158
|
+
if m is None or int(m[1]) != 1:
|
|
159
|
+
raise FactReadError(
|
|
160
|
+
"unsupported-index-format",
|
|
161
|
+
f"{label} has index format {json.dumps(fmt)}, and this reader takes 1.x",
|
|
162
|
+
)
|
|
163
|
+
facts = index.get("facts")
|
|
164
|
+
if not isinstance(facts, list) or not isinstance(index.get("package"), str):
|
|
165
|
+
raise FactReadError("not-an-index", f"{label} is not a fact index")
|
|
166
|
+
return facts
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _reader_of(version: Any) -> _Reader:
|
|
170
|
+
if version is None:
|
|
171
|
+
return _Reader("latest")
|
|
172
|
+
if version == "head" and isinstance(version, str):
|
|
173
|
+
return _Reader("head")
|
|
174
|
+
return _Reader("version", must_parse(version, "query version"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _newest_released(facts: list[Any]) -> semver.Version | None:
|
|
178
|
+
newest: semver.Version | None = None
|
|
179
|
+
for fact in facts:
|
|
180
|
+
if not isinstance(fact, dict):
|
|
181
|
+
continue
|
|
182
|
+
for row in _rows_of(fact):
|
|
183
|
+
if "pending" in row or "since" not in row:
|
|
184
|
+
continue
|
|
185
|
+
since = row["since"]
|
|
186
|
+
for s in since if isinstance(since, list) else [since]:
|
|
187
|
+
v = None if s == "next" else parse_version(s)
|
|
188
|
+
if v is not None and (newest is None or compare_semver(v, newest) > 0):
|
|
189
|
+
newest = v
|
|
190
|
+
return newest
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _removed(fact: Mapping[str, Any], reader: _Reader, latest: semver.Version | None) -> bool:
|
|
194
|
+
when = fact.get("when")
|
|
195
|
+
at = when.get("removeIn") if isinstance(when, dict) else None
|
|
196
|
+
if not isinstance(at, str):
|
|
197
|
+
return False
|
|
198
|
+
if reader.kind == "head":
|
|
199
|
+
return True
|
|
200
|
+
if at == "next":
|
|
201
|
+
return False
|
|
202
|
+
gone = parse_version(at)
|
|
203
|
+
if gone is None:
|
|
204
|
+
return False
|
|
205
|
+
current = reader.v if reader.kind == "version" else latest
|
|
206
|
+
return current is not None and compare_semver(current, gone) >= 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def select_facts(
|
|
210
|
+
index: Mapping[str, Any],
|
|
211
|
+
*,
|
|
212
|
+
version: str | None = None,
|
|
213
|
+
include: Iterable[str] = (),
|
|
214
|
+
platform: str | None = None,
|
|
215
|
+
os: str | None = None,
|
|
216
|
+
) -> list[dict[str, Any]]:
|
|
217
|
+
"""The facts one reader sees, from a parsed ``index.json`` of format 1.0 or 1.1.
|
|
218
|
+
|
|
219
|
+
Each fact has rows (``variants``, or the whole fact as one row). A row applies when its ``since`` has been
|
|
220
|
+
reached, its ``pending`` label is included, and its ``platforms`` and ``os`` list the reader's. Of the rows
|
|
221
|
+
that apply the one with the largest bound wins, then the one with more conditions. A fact without an
|
|
222
|
+
applying row, or whose ``removeIn`` has been reached, is left out. The result keeps the order of the index.
|
|
223
|
+
Each returned dict is the fact with ``variants`` replaced by ``content``, ``since`` and ``stability``
|
|
224
|
+
of the chosen row.
|
|
225
|
+
"""
|
|
226
|
+
facts = assert_index(index)
|
|
227
|
+
reader = _reader_of(version)
|
|
228
|
+
labels = list(include)
|
|
229
|
+
latest = _newest_released(facts) if reader.kind == "latest" else None
|
|
230
|
+
out: list[dict[str, Any]] = []
|
|
231
|
+
for fact in facts:
|
|
232
|
+
if not isinstance(fact, dict) or not isinstance(fact.get("id"), str):
|
|
233
|
+
continue
|
|
234
|
+
row = _choose_row(_rows_of(fact), reader, labels, platform, os, fact["id"])
|
|
235
|
+
if row is None or _removed(fact, reader, latest):
|
|
236
|
+
continue
|
|
237
|
+
selected = {k: v for k, v in fact.items() if k != "variants"}
|
|
238
|
+
if "content" in row:
|
|
239
|
+
selected["content"] = row["content"]
|
|
240
|
+
else:
|
|
241
|
+
selected.pop("content", None)
|
|
242
|
+
since = row.get("since")
|
|
243
|
+
selected["since"] = NO_SINCE if since is None else since
|
|
244
|
+
if "stability" in row:
|
|
245
|
+
selected["stability"] = row["stability"]
|
|
246
|
+
out.append(selected)
|
|
247
|
+
return out
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Semver 2.0.0 parsing and precedence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import semver
|
|
9
|
+
|
|
10
|
+
from ._errors import FactReadError
|
|
11
|
+
|
|
12
|
+
_SEMVER = re.compile(
|
|
13
|
+
r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
|
|
14
|
+
r"(?:-((?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)"
|
|
15
|
+
r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
|
16
|
+
r"(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?",
|
|
17
|
+
re.ASCII,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_version(text: Any) -> semver.Version | None:
|
|
22
|
+
"""Parse a full ``X.Y.Z`` version, or return None. Build metadata is accepted and ignored."""
|
|
23
|
+
if not isinstance(text, str):
|
|
24
|
+
return None
|
|
25
|
+
m = _SEMVER.fullmatch(text)
|
|
26
|
+
if m is None:
|
|
27
|
+
return None
|
|
28
|
+
return semver.Version(int(m[1]), int(m[2]), int(m[3]), m[4], m[5])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def must_parse(text: Any, what: str) -> semver.Version:
|
|
32
|
+
v = parse_version(text)
|
|
33
|
+
if v is None:
|
|
34
|
+
raise FactReadError("invalid-version", f'{what} "{text}" is not a semver 2.0.0 version')
|
|
35
|
+
return v
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compare_semver(a: semver.Version, b: semver.Version) -> int:
|
|
39
|
+
return a.compare(b)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compare_lines(a: semver.Version, b: semver.Version) -> int:
|
|
43
|
+
return (a.major > b.major) - (a.major < b.major) or (a.minor > b.minor) - (a.minor < b.minor)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def compare_versions(a: str, b: str) -> int:
|
|
47
|
+
"""Compare two versions by semver precedence: -1, 0 or 1. Raises ``invalid-version``."""
|
|
48
|
+
return compare_semver(must_parse(a, "version"), must_parse(b, "version"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def release_line(version: str) -> str:
|
|
52
|
+
"""The release line ``X.Y`` of a version. ``0.49.0-alpha.1`` is on line ``0.49``."""
|
|
53
|
+
v = must_parse(version, "version")
|
|
54
|
+
return f"{v.major}.{v.minor}"
|
|
File without changes
|