fact-kit 0.3.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.
fact_kit-0.3.0/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.0
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 packages/fact-kit-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 packages/fact-kit-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"
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"
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