awindex 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.
- awindex-0.1.0/LICENSE +21 -0
- awindex-0.1.0/PKG-INFO +35 -0
- awindex-0.1.0/README.md +22 -0
- awindex-0.1.0/awindex/__init__.py +7 -0
- awindex-0.1.0/awindex/cli.py +170 -0
- awindex-0.1.0/awindex/netindex.py +228 -0
- awindex-0.1.0/awindex.egg-info/PKG-INFO +35 -0
- awindex-0.1.0/awindex.egg-info/SOURCES.txt +12 -0
- awindex-0.1.0/awindex.egg-info/dependency_links.txt +1 -0
- awindex-0.1.0/awindex.egg-info/entry_points.txt +2 -0
- awindex-0.1.0/awindex.egg-info/top_level.txt +1 -0
- awindex-0.1.0/pyproject.toml +22 -0
- awindex-0.1.0/setup.cfg +4 -0
- awindex-0.1.0/tests/test_awindex.py +42 -0
awindex-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aitherium
|
|
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.
|
awindex-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: awindex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Resolve .aither keywords and read the AitherNet Index (index.aitherium.com)
|
|
5
|
+
Author: Aitherium
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://index.aitherium.com
|
|
8
|
+
Project-URL: Source, https://github.com/Aitherium/aithernet-index
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# awindex
|
|
15
|
+
|
|
16
|
+
The AitherNet Index from the shell: a hand-curated map of the internet, kept in git.
|
|
17
|
+
Every entry says who checked it and how.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install awindex
|
|
21
|
+
awindex resolve neocities # route + trust tier; exit 1 = not indexed
|
|
22
|
+
awindex search "digital garden"
|
|
23
|
+
awindex show neocities.org
|
|
24
|
+
awindex --root records check # validate a checkout of the index repo
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
It reads a local checkout of
|
|
28
|
+
[Aitherium/aithernet-index](https://github.com/Aitherium/aithernet-index) when one is
|
|
29
|
+
present, and https://index.aitherium.com otherwise. Stdlib only, Python 3.10+.
|
|
30
|
+
|
|
31
|
+
Trust is computed from evidence, never typed in: **submitted**, **reachable** (the bot
|
|
32
|
+
fetched it), **owner-proven** (the site serves `/.well-known/aithernet.json`), and
|
|
33
|
+
**vouched** (two people other than the submitter).
|
|
34
|
+
|
|
35
|
+
MIT. The index data is CC BY 4.0.
|
awindex-0.1.0/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# awindex
|
|
2
|
+
|
|
3
|
+
The AitherNet Index from the shell: a hand-curated map of the internet, kept in git.
|
|
4
|
+
Every entry says who checked it and how.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install awindex
|
|
8
|
+
awindex resolve neocities # route + trust tier; exit 1 = not indexed
|
|
9
|
+
awindex search "digital garden"
|
|
10
|
+
awindex show neocities.org
|
|
11
|
+
awindex --root records check # validate a checkout of the index repo
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
It reads a local checkout of
|
|
15
|
+
[Aitherium/aithernet-index](https://github.com/Aitherium/aithernet-index) when one is
|
|
16
|
+
present, and https://index.aitherium.com otherwise. Stdlib only, Python 3.10+.
|
|
17
|
+
|
|
18
|
+
Trust is computed from evidence, never typed in: **submitted**, **reachable** (the bot
|
|
19
|
+
fetched it), **owner-proven** (the site serves `/.well-known/aithernet.json`), and
|
|
20
|
+
**vouched** (two people other than the submitter).
|
|
21
|
+
|
|
22
|
+
MIT. The index data is CC BY 4.0.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""awindex: resolve .aither keywords and read the AitherNet Index from the shell.
|
|
2
|
+
|
|
3
|
+
awindex resolve neocities.aither # route + trust tier, exit 1 = not indexed
|
|
4
|
+
awindex search "digital garden" # matching entries
|
|
5
|
+
awindex show neocities.org # one record with its evidence
|
|
6
|
+
awindex check # validate a checkout (exit 0 / 1 / 2)
|
|
7
|
+
awindex --self-test
|
|
8
|
+
|
|
9
|
+
Reads a local checkout when one exists (records/ beside the package, or
|
|
10
|
+
AWINDEX_ROOT, or --root). Otherwise it reads the published index at
|
|
11
|
+
https://index.aitherium.com, so a plain `pip install` works with no clone.
|
|
12
|
+
Stdlib only.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from . import __version__, netindex
|
|
26
|
+
|
|
27
|
+
BASE = "https://index.aitherium.com"
|
|
28
|
+
UA = f"awindex/{__version__}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fetch(path: str) -> Optional[dict]:
|
|
32
|
+
req = urllib.request.Request(BASE + path, headers={"User-Agent": UA})
|
|
33
|
+
try:
|
|
34
|
+
with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310 (fixed https base)
|
|
35
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
36
|
+
except urllib.error.HTTPError as exc:
|
|
37
|
+
if exc.code == 404:
|
|
38
|
+
return None
|
|
39
|
+
raise
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _local(root: Optional[Path]) -> Optional[netindex.Index]:
|
|
43
|
+
root = root or netindex.DEFAULT_ROOT
|
|
44
|
+
if Path(root).is_dir():
|
|
45
|
+
records, _ = netindex.load(Path(root))
|
|
46
|
+
if records:
|
|
47
|
+
return netindex.Index(records)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _record_dict(r: netindex.Record) -> dict:
|
|
52
|
+
return {"id": r.id, "kind": r.kind, "title": r.title, "url": r.url, "tags": r.tags,
|
|
53
|
+
"names": r.names, "route": r.route, "edges": r.edges,
|
|
54
|
+
"validated": r.provenance.get("validated", {}),
|
|
55
|
+
"trust": netindex.TIERS[netindex.trust(r)]}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_resolve(name: str, idx: Optional[netindex.Index]) -> int:
|
|
59
|
+
name = name.strip().lower().rstrip(".")
|
|
60
|
+
if not name.endswith(netindex.NAME_SUFFIX):
|
|
61
|
+
name += netindex.NAME_SUFFIX
|
|
62
|
+
if idx is not None:
|
|
63
|
+
hit = idx.resolve(name)
|
|
64
|
+
if hit:
|
|
65
|
+
hit["trust"] = netindex.TIERS[netindex.trust(idx.records[hit["id"]])]
|
|
66
|
+
else:
|
|
67
|
+
hit = _fetch(f"/n/{name}.json")
|
|
68
|
+
if not hit:
|
|
69
|
+
print(f"NXDOMAIN {name}")
|
|
70
|
+
return 1
|
|
71
|
+
print(json.dumps(hit, indent=2))
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _all(idx: Optional[netindex.Index]) -> list:
|
|
76
|
+
if idx is not None:
|
|
77
|
+
return [_record_dict(r) for r in idx.records.values()]
|
|
78
|
+
data = _fetch("/index.json") or {}
|
|
79
|
+
return data.get("records", [])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_search(query: str, idx: Optional[netindex.Index], limit: int) -> int:
|
|
83
|
+
toks = [t for t in query.lower().split() if t]
|
|
84
|
+
scored = []
|
|
85
|
+
for r in _all(idx):
|
|
86
|
+
hay = " ".join([r["id"], r["title"], *r.get("tags", []), *r.get("names", [])]).lower()
|
|
87
|
+
s = sum(1 for t in toks if t in hay)
|
|
88
|
+
if s:
|
|
89
|
+
scored.append((-s, r["id"], r))
|
|
90
|
+
scored.sort(key=lambda x: (x[0], x[1]))
|
|
91
|
+
for _, _, r in scored[:limit]:
|
|
92
|
+
print(f"{r['trust']:<13} {r['kind']:<8} {r['title']} {r.get('url', '')}")
|
|
93
|
+
return 0 if scored else 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_show(rid: str, idx: Optional[netindex.Index]) -> int:
|
|
97
|
+
for r in _all(idx):
|
|
98
|
+
if r["id"] == rid:
|
|
99
|
+
print(json.dumps(r, indent=2))
|
|
100
|
+
return 0
|
|
101
|
+
print(f"not indexed: {rid}")
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_check(root: Optional[Path]) -> int:
|
|
106
|
+
root = Path(root or netindex.DEFAULT_ROOT)
|
|
107
|
+
if not root.is_dir():
|
|
108
|
+
print(f"could not judge: {root} is not a directory")
|
|
109
|
+
return 2
|
|
110
|
+
records, findings = netindex.load(root)
|
|
111
|
+
if not records:
|
|
112
|
+
print(f"could not judge: no records under {root}")
|
|
113
|
+
return 2
|
|
114
|
+
findings += netindex.validate(records)
|
|
115
|
+
for f in findings:
|
|
116
|
+
print(f)
|
|
117
|
+
print(f"awindex: {len(records)} records, {len(findings)} findings")
|
|
118
|
+
return 1 if findings else 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def self_test() -> int:
|
|
122
|
+
good = netindex._parse(None, {
|
|
123
|
+
"id": "a", "kind": "site", "title": "A", "url": "https://a.example",
|
|
124
|
+
"tags": ["alpha"], "names": ["a.aither"],
|
|
125
|
+
"route": {"type": "web", "target": "https://a.example"},
|
|
126
|
+
"provenance": {"added_by": "t", "sources": ["x"],
|
|
127
|
+
"validated": {"at": "2026-01-01", "by": "t", "method": "http-200"}}})
|
|
128
|
+
idx = netindex.Index([good])
|
|
129
|
+
checks = {
|
|
130
|
+
"resolve hit": idx.resolve("a.aither") is not None,
|
|
131
|
+
"resolve miss": idx.resolve("zz.aither") is None,
|
|
132
|
+
"trust reachable": netindex.TIERS[netindex.trust(good)] == "reachable",
|
|
133
|
+
"search": [r.id for r in idx.search("alpha")] == ["a"],
|
|
134
|
+
}
|
|
135
|
+
for k, v in checks.items():
|
|
136
|
+
print(f"{'PASS' if v else 'FAIL'} {k}")
|
|
137
|
+
return 0 if all(checks.values()) else 1
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main(argv: Optional[list] = None) -> int:
|
|
141
|
+
ap = argparse.ArgumentParser(prog="awindex", description=__doc__.splitlines()[0])
|
|
142
|
+
ap.add_argument("--root", type=Path, help="a records/ directory (default: local or web)")
|
|
143
|
+
ap.add_argument("--web", action="store_true", help="ignore any local checkout")
|
|
144
|
+
ap.add_argument("--self-test", action="store_true")
|
|
145
|
+
ap.add_argument("--version", action="version", version=f"awindex {__version__}")
|
|
146
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
147
|
+
sub.add_parser("resolve").add_argument("name")
|
|
148
|
+
s = sub.add_parser("search")
|
|
149
|
+
s.add_argument("query")
|
|
150
|
+
s.add_argument("--limit", type=int, default=20)
|
|
151
|
+
sub.add_parser("show").add_argument("id")
|
|
152
|
+
sub.add_parser("check")
|
|
153
|
+
a = ap.parse_args(argv)
|
|
154
|
+
if a.self_test:
|
|
155
|
+
return self_test()
|
|
156
|
+
if a.cmd == "check":
|
|
157
|
+
return cmd_check(a.root)
|
|
158
|
+
idx = None if a.web else _local(a.root)
|
|
159
|
+
if a.cmd == "resolve":
|
|
160
|
+
return cmd_resolve(a.name, idx)
|
|
161
|
+
if a.cmd == "search":
|
|
162
|
+
return cmd_search(a.query, idx, a.limit)
|
|
163
|
+
if a.cmd == "show":
|
|
164
|
+
return cmd_show(a.id, idx)
|
|
165
|
+
ap.print_help()
|
|
166
|
+
return 2
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
sys.exit(main())
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""AitherNet Index: a curated, validated internet graph that lives in git.
|
|
2
|
+
|
|
3
|
+
One JSON file per entity under ``records/``. The file
|
|
4
|
+
IS the record; git history is the audit log; a pull request is how a
|
|
5
|
+
contribution arrives (from a person, awfind/awresearch harvest, ShadowBroker
|
|
6
|
+
gates or the Tagalong Town residents). Nothing here crawls or fetches: this
|
|
7
|
+
module loads, validates, resolves names and walks edges. Validation that needs
|
|
8
|
+
the network (is the URL alive) is recorded IN the record by whoever checked it,
|
|
9
|
+
under ``provenance.validated``, so a reviewer can re-run it.
|
|
10
|
+
|
|
11
|
+
Name resolution is the AitherNet DNS/router seam: ``resolve("docs.aither")``
|
|
12
|
+
returns the route a client should take. Two records claiming one name is a
|
|
13
|
+
validation error (NI003), never a silent first-wins.
|
|
14
|
+
|
|
15
|
+
Python 3.10 compatible; stdlib only.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import datetime as _dt
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Iterable, Optional
|
|
26
|
+
|
|
27
|
+
# A checkout of the index repo (records/ beside the package), or AWINDEX_ROOT.
|
|
28
|
+
# A pip install has no records/: the CLI then reads index.aitherium.com instead.
|
|
29
|
+
DEFAULT_ROOT = Path(os.environ.get("AWINDEX_ROOT")
|
|
30
|
+
or Path(__file__).resolve().parents[1] / "records")
|
|
31
|
+
|
|
32
|
+
# garden = a personal digital garden (Neocities-style); room = an AitherRelay channel;
|
|
33
|
+
# space = an AitherSpace. A .aither name on any of them is its AOL-style keyword.
|
|
34
|
+
KINDS = {"site", "service", "org", "gate", "world", "dataset",
|
|
35
|
+
"garden", "room", "space"}
|
|
36
|
+
ROUTE_TYPES = {"web", "aithernet", "relay-gate", "world", "relay-room", "space"}
|
|
37
|
+
EDGE_RELS = {"owned_by", "links_to", "mirrors", "part_of", "bridges", "about", "alternative_to"}
|
|
38
|
+
NAME_SUFFIX = ".aither"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Finding:
|
|
43
|
+
code: str
|
|
44
|
+
record: str
|
|
45
|
+
message: str
|
|
46
|
+
|
|
47
|
+
def __str__(self) -> str:
|
|
48
|
+
return f"{self.code} {self.record}: {self.message}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Record:
|
|
53
|
+
id: str
|
|
54
|
+
kind: str
|
|
55
|
+
title: str
|
|
56
|
+
url: str = ""
|
|
57
|
+
tags: list = field(default_factory=list)
|
|
58
|
+
names: list = field(default_factory=list)
|
|
59
|
+
route: dict = field(default_factory=dict)
|
|
60
|
+
edges: list = field(default_factory=list)
|
|
61
|
+
provenance: dict = field(default_factory=dict)
|
|
62
|
+
path: Optional[Path] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse(path: Path, raw: dict) -> Record:
|
|
66
|
+
return Record(
|
|
67
|
+
id=raw.get("id", ""),
|
|
68
|
+
kind=raw.get("kind", ""),
|
|
69
|
+
title=raw.get("title", ""),
|
|
70
|
+
url=raw.get("url", ""),
|
|
71
|
+
tags=list(raw.get("tags") or []),
|
|
72
|
+
names=list(raw.get("names") or []),
|
|
73
|
+
route=dict(raw.get("route") or {}),
|
|
74
|
+
edges=list(raw.get("edges") or []),
|
|
75
|
+
provenance=dict(raw.get("provenance") or {}),
|
|
76
|
+
path=path,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load(root: Path = DEFAULT_ROOT) -> "tuple[list[Record], list[Finding]]":
|
|
81
|
+
"""Load every record. Unreadable JSON is a finding, not an exception."""
|
|
82
|
+
records: list = []
|
|
83
|
+
findings: list = []
|
|
84
|
+
for path in sorted(Path(root).rglob("*.json")):
|
|
85
|
+
try:
|
|
86
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
87
|
+
except (OSError, ValueError) as exc:
|
|
88
|
+
findings.append(Finding("NI000", path.name, f"unreadable JSON: {exc}"))
|
|
89
|
+
continue
|
|
90
|
+
if not isinstance(raw, dict):
|
|
91
|
+
findings.append(Finding("NI000", path.name, "top level is not an object"))
|
|
92
|
+
continue
|
|
93
|
+
records.append(_parse(path, raw))
|
|
94
|
+
return records, findings
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def validate(records: Iterable[Record], today: Optional[_dt.date] = None) -> "list[Finding]":
|
|
98
|
+
"""Every rule the index must hold. Empty list = clean."""
|
|
99
|
+
today = today or _dt.date.today()
|
|
100
|
+
recs = list(records)
|
|
101
|
+
out: list = []
|
|
102
|
+
ids = {}
|
|
103
|
+
for r in recs:
|
|
104
|
+
if r.id in ids:
|
|
105
|
+
out.append(Finding("NI002", r.id, f"duplicate id (also {ids[r.id]})"))
|
|
106
|
+
ids[r.id] = r.path.name if r.path else r.id
|
|
107
|
+
|
|
108
|
+
names: dict = {}
|
|
109
|
+
for r in recs:
|
|
110
|
+
rid = r.id or (r.path.name if r.path else "?")
|
|
111
|
+
if not r.id or not r.title:
|
|
112
|
+
out.append(Finding("NI001", rid, "id and title are required"))
|
|
113
|
+
if r.kind not in KINDS:
|
|
114
|
+
out.append(Finding("NI001", rid, f"kind {r.kind!r} not in {sorted(KINDS)}"))
|
|
115
|
+
if r.path is not None and r.path.stem != r.id.replace(":", "_"):
|
|
116
|
+
out.append(Finding("NI002", rid, f"file name {r.path.name} != id"))
|
|
117
|
+
if r.url and not r.url.startswith("https://"):
|
|
118
|
+
out.append(Finding("NI007", rid, "url must be https://"))
|
|
119
|
+
for n in r.names:
|
|
120
|
+
if not isinstance(n, str) or not n.endswith(NAME_SUFFIX) or n != n.lower():
|
|
121
|
+
msg = f"name {n!r} must be lowercase and end in {NAME_SUFFIX}"
|
|
122
|
+
out.append(Finding("NI005", rid, msg))
|
|
123
|
+
if n in names:
|
|
124
|
+
out.append(Finding("NI003", rid, f"name {n!r} already claimed by {names[n]}"))
|
|
125
|
+
names.setdefault(n, rid)
|
|
126
|
+
if r.names:
|
|
127
|
+
rtype = r.route.get("type")
|
|
128
|
+
if rtype not in ROUTE_TYPES or not r.route.get("target"):
|
|
129
|
+
msg = f"a named record needs route.type in {sorted(ROUTE_TYPES)} and route.target"
|
|
130
|
+
out.append(Finding("NI006", rid, msg))
|
|
131
|
+
elif rtype == "web" and not str(r.route["target"]).startswith("https://"):
|
|
132
|
+
out.append(Finding("NI007", rid, "web route target must be https://"))
|
|
133
|
+
for e in r.edges:
|
|
134
|
+
rel, to = (e or {}).get("rel"), (e or {}).get("to")
|
|
135
|
+
if rel not in EDGE_RELS:
|
|
136
|
+
out.append(Finding("NI004", rid, f"edge rel {rel!r} not in {sorted(EDGE_RELS)}"))
|
|
137
|
+
if to not in ids:
|
|
138
|
+
out.append(Finding("NI004", rid, f"edge to unknown record {to!r}"))
|
|
139
|
+
prov = r.provenance
|
|
140
|
+
val = prov.get("validated") or {}
|
|
141
|
+
if not prov.get("added_by") or not prov.get("sources"):
|
|
142
|
+
msg = "provenance.added_by and provenance.sources are required"
|
|
143
|
+
out.append(Finding("NI008", rid, msg))
|
|
144
|
+
if not val.get("at") or not val.get("by") or not val.get("method"):
|
|
145
|
+
out.append(Finding("NI008", rid, "provenance.validated needs at, by, method"))
|
|
146
|
+
else:
|
|
147
|
+
try:
|
|
148
|
+
when = _dt.date.fromisoformat(str(val["at"]))
|
|
149
|
+
except ValueError:
|
|
150
|
+
out.append(Finding("NI008", rid, f"validated.at {val['at']!r} is not YYYY-MM-DD"))
|
|
151
|
+
else:
|
|
152
|
+
if when > today:
|
|
153
|
+
out.append(Finding("NI008", rid, f"validated.at {when} is in the future"))
|
|
154
|
+
seen = set()
|
|
155
|
+
for v in prov.get("vouches") or []:
|
|
156
|
+
who = (v or {}).get("by")
|
|
157
|
+
if not who or not (v or {}).get("at"):
|
|
158
|
+
out.append(Finding("NI009", rid, "a vouch needs by and at"))
|
|
159
|
+
elif who == prov.get("added_by"):
|
|
160
|
+
out.append(Finding("NI009", rid, f"{who} cannot vouch for their own submission"))
|
|
161
|
+
elif who in seen:
|
|
162
|
+
out.append(Finding("NI009", rid, f"{who} vouched twice"))
|
|
163
|
+
seen.add(who)
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Trust is COMPUTED from evidence in the record, never typed in by a contributor.
|
|
168
|
+
# A submitter can claim anything; only these facts move an entry up a tier.
|
|
169
|
+
TIERS = {0: "submitted", 1: "reachable", 2: "owner-proven", 3: "vouched"}
|
|
170
|
+
_REACH_METHODS = {"http-200"}
|
|
171
|
+
_OWNER_METHODS = {"well-known", "owner-asserted"}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def trust(r: Record) -> int:
|
|
175
|
+
"""0 submitted · 1 reachable (bot fetched it) · 2 owner-proven (the site serves
|
|
176
|
+
/.well-known/aithernet.json naming this id) · 3 vouched (reachable or better AND
|
|
177
|
+
at least two distinct people other than the submitter vouched)."""
|
|
178
|
+
method = (r.provenance.get("validated") or {}).get("method", "")
|
|
179
|
+
tier = 2 if method in _OWNER_METHODS else 1 if method in _REACH_METHODS else 0
|
|
180
|
+
vouchers = {v.get("by") for v in r.provenance.get("vouches") or [] if v.get("by")}
|
|
181
|
+
vouchers.discard(r.provenance.get("added_by"))
|
|
182
|
+
if tier >= 1 and len(vouchers) >= 2:
|
|
183
|
+
tier = 3
|
|
184
|
+
return tier
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class Index:
|
|
188
|
+
"""In-memory view: name -> route, id -> record, edge walks."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, records: Iterable[Record]):
|
|
191
|
+
self.records = {r.id: r for r in records}
|
|
192
|
+
self.names = {n: r.id for r in self.records.values() for n in r.names}
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def from_root(cls, root: Path = DEFAULT_ROOT) -> "Index":
|
|
196
|
+
recs, _ = load(root)
|
|
197
|
+
return cls(recs)
|
|
198
|
+
|
|
199
|
+
def resolve(self, name: str) -> Optional[dict]:
|
|
200
|
+
"""The AitherNet DNS seam. None = NXDOMAIN; never a guess."""
|
|
201
|
+
rid = self.names.get(name.strip().lower().rstrip("."))
|
|
202
|
+
if rid is None:
|
|
203
|
+
return None
|
|
204
|
+
r = self.records[rid]
|
|
205
|
+
return {"name": name, "id": r.id, "kind": r.kind, **r.route}
|
|
206
|
+
|
|
207
|
+
def neighbours(self, rid: str) -> "list[tuple[str, str, str]]":
|
|
208
|
+
"""(direction, rel, other_id) for every edge touching ``rid``."""
|
|
209
|
+
out = []
|
|
210
|
+
for r in self.records.values():
|
|
211
|
+
for e in r.edges:
|
|
212
|
+
if r.id == rid:
|
|
213
|
+
out.append(("out", e.get("rel", ""), e.get("to", "")))
|
|
214
|
+
elif e.get("to") == rid:
|
|
215
|
+
out.append(("in", e.get("rel", ""), r.id))
|
|
216
|
+
return out
|
|
217
|
+
|
|
218
|
+
def search(self, query: str, limit: int = 10) -> "list[Record]":
|
|
219
|
+
"""Plain token match over id/title/tags; the AitherSearch provider seam."""
|
|
220
|
+
toks = [t for t in query.lower().split() if t]
|
|
221
|
+
scored = []
|
|
222
|
+
for r in self.records.values():
|
|
223
|
+
hay = " ".join([r.id, r.title, *r.tags, *r.names]).lower()
|
|
224
|
+
s = sum(1 for t in toks if t in hay)
|
|
225
|
+
if s:
|
|
226
|
+
scored.append((s, r.id, r))
|
|
227
|
+
scored.sort(key=lambda x: (-x[0], x[1]))
|
|
228
|
+
return [r for _, _, r in scored[:limit]]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: awindex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Resolve .aither keywords and read the AitherNet Index (index.aitherium.com)
|
|
5
|
+
Author: Aitherium
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://index.aitherium.com
|
|
8
|
+
Project-URL: Source, https://github.com/Aitherium/aithernet-index
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# awindex
|
|
15
|
+
|
|
16
|
+
The AitherNet Index from the shell: a hand-curated map of the internet, kept in git.
|
|
17
|
+
Every entry says who checked it and how.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install awindex
|
|
21
|
+
awindex resolve neocities # route + trust tier; exit 1 = not indexed
|
|
22
|
+
awindex search "digital garden"
|
|
23
|
+
awindex show neocities.org
|
|
24
|
+
awindex --root records check # validate a checkout of the index repo
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
It reads a local checkout of
|
|
28
|
+
[Aitherium/aithernet-index](https://github.com/Aitherium/aithernet-index) when one is
|
|
29
|
+
present, and https://index.aitherium.com otherwise. Stdlib only, Python 3.10+.
|
|
30
|
+
|
|
31
|
+
Trust is computed from evidence, never typed in: **submitted**, **reachable** (the bot
|
|
32
|
+
fetched it), **owner-proven** (the site serves `/.well-known/aithernet.json`), and
|
|
33
|
+
**vouched** (two people other than the submitter).
|
|
34
|
+
|
|
35
|
+
MIT. The index data is CC BY 4.0.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
awindex/__init__.py
|
|
5
|
+
awindex/cli.py
|
|
6
|
+
awindex/netindex.py
|
|
7
|
+
awindex.egg-info/PKG-INFO
|
|
8
|
+
awindex.egg-info/SOURCES.txt
|
|
9
|
+
awindex.egg-info/dependency_links.txt
|
|
10
|
+
awindex.egg-info/entry_points.txt
|
|
11
|
+
awindex.egg-info/top_level.txt
|
|
12
|
+
tests/test_awindex.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
awindex
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "awindex"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Resolve .aither keywords and read the AitherNet Index (index.aitherium.com)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "Aitherium"}]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://index.aitherium.com"
|
|
16
|
+
Source = "https://github.com/Aitherium/aithernet-index"
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
awindex = "awindex.cli:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
packages = ["awindex"]
|
awindex-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""awindex contract: offline resolve, search, trust and check over a temp checkout."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from awindex import cli, netindex
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _write(root, rec):
|
|
8
|
+
(root / f"{rec['id']}.json").write_text(json.dumps(rec), encoding="utf-8")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
REC = {"id": "a.example", "kind": "site", "title": "A", "url": "https://a.example",
|
|
12
|
+
"tags": ["alpha"], "names": ["a.aither"],
|
|
13
|
+
"route": {"type": "web", "target": "https://a.example"},
|
|
14
|
+
"provenance": {"added_by": "t", "sources": ["x"],
|
|
15
|
+
"validated": {"at": "2026-01-01", "by": "t", "method": "http-200"}}}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_self_test_passes():
|
|
19
|
+
assert cli.main(["--self-test"]) == 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_resolve_and_search_offline(tmp_path, capsys):
|
|
23
|
+
_write(tmp_path, REC)
|
|
24
|
+
assert cli.main(["--root", str(tmp_path), "resolve", "a"]) == 0
|
|
25
|
+
assert '"target": "https://a.example"' in capsys.readouterr().out
|
|
26
|
+
assert cli.main(["--root", str(tmp_path), "resolve", "zz"]) == 1
|
|
27
|
+
assert cli.main(["--root", str(tmp_path), "search", "alpha"]) == 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_check_flags_a_name_collision(tmp_path):
|
|
31
|
+
_write(tmp_path, REC)
|
|
32
|
+
_write(tmp_path, {**REC, "id": "b.example"})
|
|
33
|
+
assert cli.main(["--root", str(tmp_path), "check"]) == 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_check_on_nothing_cannot_judge(tmp_path):
|
|
37
|
+
assert cli.main(["--root", str(tmp_path), "check"]) == 2
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_trust_is_computed_not_typed():
|
|
41
|
+
r = netindex._parse(None, {**REC, "trust": "vouched"})
|
|
42
|
+
assert netindex.TIERS[netindex.trust(r)] == "reachable"
|