runlight 0.3.2__tar.gz → 0.4.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.
- {runlight-0.3.2 → runlight-0.4.0}/PKG-INFO +1 -1
- {runlight-0.3.2 → runlight-0.4.0}/pyproject.toml +1 -1
- runlight-0.4.0/src/runlight/agentcheck.py +227 -0
- runlight-0.4.0/src/runlight/assets/agent-ranges.json +849 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets/build.json +3 -3
- runlight-0.4.0/src/runlight/assets/dashboard.css +1 -0
- runlight-0.4.0/src/runlight/assets/dashboard.js +131 -0
- runlight-0.4.0/src/runlight/assets/locales.json +7 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets.py +9 -1
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/core.py +53 -3
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/data/agents.py +21 -15
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/routes.py +7 -2
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/server/agents.py +15 -7
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/server/cli.py +16 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/server/standalone.py +3 -1
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/store/sql_store.py +13 -4
- runlight-0.3.2/src/runlight/assets/dashboard.css +0 -1
- runlight-0.3.2/src/runlight/assets/dashboard.js +0 -131
- runlight-0.3.2/src/runlight/assets/locales.json +0 -7
- {runlight-0.3.2 → runlight-0.4.0}/.gitignore +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/LICENSE +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/README.md +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/__init__.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/_aesgcm.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/_intl.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/_js.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/accounts/__init__.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/accounts/auth.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/accounts/crypto.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/accounts/pages.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/accounts/web.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/asgi.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets/assistant-action.json +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets/pick-return.js +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets/picker.js +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets/tracker.js +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assets/world.json +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/assistant.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/body.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/brand.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/connect.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/data/__init__.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/data/intl.json +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/data/sources.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/db.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/django/__init__.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/django/apps.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/django/management/__init__.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/django/management/commands/__init__.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/django/management/commands/runlight_check.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/env.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/fastapi.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/flask.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/funnels.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/geo.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/goals.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/hash.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/http/__init__.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/http/fetcher.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/http/headers.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/http/message.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/http/search_params.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/http/url.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/icon.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/__init__.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/bitly.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/csvvisits.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/dub.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/http.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/index.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/rebrandly.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/shortio.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/types.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/umami.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/visits.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/importers/write.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/journeys.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/limit.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/links.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mail/__init__.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mail/secret.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mail/ses.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mail/smtp.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mail/transports.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mcp.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/messages.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/mmdb.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/oauth.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/payload.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/py.typed +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/query.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/reports.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/safefetch.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/serve.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/server/__init__.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/server/dbip.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/server/secret.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/sources.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/store/__init__.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/store/sql.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/store/stores.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/time.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/ua.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/version.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/wsgi.py +0 -0
- {runlight-0.3.2 → runlight-0.4.0}/src/runlight/zip.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: runlight
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Privacy friendly web analytics that lives inside your Python app. Mount a route, add a script tag, read your stats at /runlight.
|
|
5
5
|
Project-URL: Homepage, https://runlight.sh
|
|
6
6
|
Project-URL: Documentation, https://runlight.sh/docs/python/
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runlight"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Privacy friendly web analytics that lives inside your Python app. Mount a route, add a script tag, read your stats at /runlight."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Checks a request that names an AI agent against the addresses that agent's company publishes. Every list is in
|
|
2
|
+
the same shape, {"prefixes": [{"ipv4Prefix": "a.b.c.d/n"} or {"ipv6Prefix": "x::/n"}]}. A port of the TypeScript
|
|
3
|
+
SDK's agentcheck.ts.
|
|
4
|
+
|
|
5
|
+
The answer is "verified" when the address is in the list, "mismatch" when the company publishes a list and the
|
|
6
|
+
address is not in it, and "" when there is nothing to check against: no list for that agent, or no usable address.
|
|
7
|
+
The address is only compared, never kept.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import _js
|
|
17
|
+
from .http import Fetcher
|
|
18
|
+
|
|
19
|
+
# A list of more prefixes than this, or a reply bigger than MAX_LIST_BYTES, is not a list we read.
|
|
20
|
+
MAX_PREFIXES = 20_000
|
|
21
|
+
MAX_LIST_BYTES = 4_000_000
|
|
22
|
+
|
|
23
|
+
_DIGITS = re.compile(r"[0-9]{1,3}\Z")
|
|
24
|
+
_HEX_TEXT = re.compile(r"[0-9a-fA-F:.]+\Z")
|
|
25
|
+
_GROUP = re.compile(r"[0-9a-fA-F]{1,4}\Z")
|
|
26
|
+
|
|
27
|
+
# An address or a prefix: (family, number), family 4 or 6.
|
|
28
|
+
Address = tuple[int, int]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ipv4(text: str) -> int | None:
|
|
32
|
+
parts = text.split(".")
|
|
33
|
+
if len(parts) != 4:
|
|
34
|
+
return None
|
|
35
|
+
n = 0
|
|
36
|
+
for part in parts:
|
|
37
|
+
if not _DIGITS.match(part):
|
|
38
|
+
return None
|
|
39
|
+
byte = int(part)
|
|
40
|
+
if byte > 255:
|
|
41
|
+
return None
|
|
42
|
+
n = n * 256 + byte
|
|
43
|
+
return n
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ipv6(text: str) -> int | None:
|
|
47
|
+
if not _HEX_TEXT.match(text) or len(text.split("::")) > 2:
|
|
48
|
+
return None
|
|
49
|
+
# A dotted IPv4 tail ("::ffff:1.2.3.4") stands for the last two groups.
|
|
50
|
+
groups = text
|
|
51
|
+
dot = text.rfind(":")
|
|
52
|
+
if "." in text:
|
|
53
|
+
tail = _ipv4(text[dot + 1 :])
|
|
54
|
+
if tail is None:
|
|
55
|
+
return None
|
|
56
|
+
groups = f"{text[: dot + 1]}{tail >> 16:x}:{tail & 0xFFFF:x}"
|
|
57
|
+
pieces = groups.split("::")
|
|
58
|
+
head = pieces[0]
|
|
59
|
+
rest = pieces[1] if len(pieces) > 1 else None
|
|
60
|
+
left = head.split(":") if head else []
|
|
61
|
+
right = [] if rest is None else rest.split(":") if rest else []
|
|
62
|
+
missing = 8 - len(left) - len(right)
|
|
63
|
+
if (missing != 0) if rest is None else (missing < 1):
|
|
64
|
+
return None
|
|
65
|
+
every = [*left, *(["0"] * (0 if rest is None else missing)), *right]
|
|
66
|
+
n = 0
|
|
67
|
+
for group in every:
|
|
68
|
+
if not _GROUP.match(group):
|
|
69
|
+
return None
|
|
70
|
+
n = (n << 16) | int(group, 16)
|
|
71
|
+
return n
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_address(text: str) -> Address | None:
|
|
75
|
+
"""An address as the check reads it: surrounding space, [brackets], and an IPv6 zone ("%eth0") are ignored, and
|
|
76
|
+
an IPv4 address written as IPv6 (::ffff:1.2.3.4) is read as IPv4. (4, n) or (6, n), or None when it is not one."""
|
|
77
|
+
s = _js.trim(text)
|
|
78
|
+
if s.startswith("[") and s.endswith("]"):
|
|
79
|
+
s = s[1:-1]
|
|
80
|
+
zone = s.find("%")
|
|
81
|
+
if zone != -1:
|
|
82
|
+
s = s[:zone]
|
|
83
|
+
if not s:
|
|
84
|
+
return None
|
|
85
|
+
if ":" not in s:
|
|
86
|
+
n = _ipv4(s)
|
|
87
|
+
return None if n is None else (4, n)
|
|
88
|
+
n = _ipv6(s)
|
|
89
|
+
if n is None:
|
|
90
|
+
return None
|
|
91
|
+
if n >> 32 == 0xFFFF:
|
|
92
|
+
return (4, n & 0xFFFFFFFF)
|
|
93
|
+
return (6, n)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_prefix(text: str) -> tuple[int, int, int] | None:
|
|
97
|
+
"""A prefix such as 20.171.206.0/24 or 2600:1f28::/56 as (family, base, bits), or None. Bits past the length are
|
|
98
|
+
ignored."""
|
|
99
|
+
slash = text.find("/")
|
|
100
|
+
if slash == -1:
|
|
101
|
+
return None
|
|
102
|
+
bits_text = text[slash + 1 :]
|
|
103
|
+
if not _DIGITS.match(bits_text):
|
|
104
|
+
return None
|
|
105
|
+
bits = int(bits_text)
|
|
106
|
+
head = text[:slash]
|
|
107
|
+
if ":" in head:
|
|
108
|
+
n = _ipv6(head)
|
|
109
|
+
if n is None or bits > 128:
|
|
110
|
+
return None
|
|
111
|
+
return (6, n, bits)
|
|
112
|
+
n = _ipv4(head)
|
|
113
|
+
if n is None or bits > 32:
|
|
114
|
+
return None
|
|
115
|
+
return (4, n, bits)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _covers(prefix: tuple[int, int, int], address: Address) -> bool:
|
|
119
|
+
family, base, bits = prefix
|
|
120
|
+
if family != address[0]:
|
|
121
|
+
return False
|
|
122
|
+
shift = (32 if family == 4 else 128) - bits
|
|
123
|
+
return address[1] >> shift == base >> shift
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def read_range_list(body: Any) -> list[str] | None:
|
|
127
|
+
"""The prefixes in a published list, or None when the reply is not a list in the expected shape (not an object,
|
|
128
|
+
no prefixes, an entry that is not a prefix, or too many). One bad entry rejects the whole list, so a half broken
|
|
129
|
+
download never replaces a good copy."""
|
|
130
|
+
if not isinstance(body, dict):
|
|
131
|
+
return None
|
|
132
|
+
prefixes = body.get("prefixes")
|
|
133
|
+
if not isinstance(prefixes, list) or len(prefixes) == 0 or len(prefixes) > MAX_PREFIXES:
|
|
134
|
+
return None
|
|
135
|
+
out: list[str] = []
|
|
136
|
+
for entry in prefixes:
|
|
137
|
+
if not isinstance(entry, dict):
|
|
138
|
+
return None
|
|
139
|
+
v4 = entry.get("ipv4Prefix")
|
|
140
|
+
v6 = entry.get("ipv6Prefix")
|
|
141
|
+
text = v4 if isinstance(v4, str) else v6 if isinstance(v6, str) else None
|
|
142
|
+
if text is None:
|
|
143
|
+
return None
|
|
144
|
+
prefix = _parse_prefix(_js.trim(text))
|
|
145
|
+
if prefix is None or isinstance(v4, str) != (prefix[0] == 4):
|
|
146
|
+
return None
|
|
147
|
+
out.append(_js.trim(text))
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def download_range_list(fetcher: Fetcher, url: str, timeout_ms: int = 15_000) -> list[str] | None:
|
|
152
|
+
"""Downloads one published list. None when it cannot be fetched or read; the caller keeps what it had."""
|
|
153
|
+
try:
|
|
154
|
+
response = fetcher.fetch(
|
|
155
|
+
url,
|
|
156
|
+
{
|
|
157
|
+
"headers": {"accept": "application/json", "user-agent": "Runlight (+https://runlight.sh)"},
|
|
158
|
+
"redirect": "follow",
|
|
159
|
+
"timeoutMs": timeout_ms,
|
|
160
|
+
"maxBytes": MAX_LIST_BYTES,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
if not response.ok:
|
|
164
|
+
return None
|
|
165
|
+
length = _js.number(response.headers.get("content-length") or 0)
|
|
166
|
+
if length > MAX_LIST_BYTES:
|
|
167
|
+
return None
|
|
168
|
+
content = response.content()
|
|
169
|
+
if len(content) > MAX_LIST_BYTES:
|
|
170
|
+
return None
|
|
171
|
+
return read_range_list(_js.loads(content))
|
|
172
|
+
except Exception:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
_compiled: dict[int, tuple[list[str], list[tuple[int, int, int]]]] = {}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _prefixes_of(ranges: list[str]) -> list[tuple[int, int, int]]:
|
|
180
|
+
# Kept with the list itself, so a list replaced by a download is parsed again and the old one is let go.
|
|
181
|
+
cached = _compiled.get(id(ranges))
|
|
182
|
+
if cached is not None and cached[0] is ranges:
|
|
183
|
+
return cached[1]
|
|
184
|
+
parsed = [p for p in (_parse_prefix(text) for text in ranges) if p is not None]
|
|
185
|
+
if len(_compiled) > 64:
|
|
186
|
+
_compiled.clear()
|
|
187
|
+
_compiled[id(ranges)] = (ranges, parsed)
|
|
188
|
+
return parsed
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def in_ranges(ip: str, ranges: list[str]) -> bool:
|
|
192
|
+
"""Whether `ip` is inside any of the prefixes. False for an address that cannot be read."""
|
|
193
|
+
address = parse_address(ip)
|
|
194
|
+
return address is not None and any(_covers(p, address) for p in _prefixes_of(ranges))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def check_agent(agent: Mapping[str, Any], ip: str, lists: Mapping[str, list[str]]) -> str:
|
|
198
|
+
"""The check for one request that names `agent`, made from `ip` (see the top of this module): "verified",
|
|
199
|
+
"mismatch", or ""."""
|
|
200
|
+
url = agent.get("ranges")
|
|
201
|
+
found = lists.get(url) if url else None
|
|
202
|
+
if not found or parse_address(ip) is None:
|
|
203
|
+
return ""
|
|
204
|
+
return "verified" if in_ranges(ip, found) else "mismatch"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def saved_lists(stored: str | None) -> dict[str, Any] | None:
|
|
208
|
+
"""The lists saved in the "agent-ranges" setting, {"at", "lists"}, each list read as a download is. None when
|
|
209
|
+
nothing usable is saved."""
|
|
210
|
+
if not stored:
|
|
211
|
+
return None
|
|
212
|
+
try:
|
|
213
|
+
parsed = _js.loads(stored)
|
|
214
|
+
except Exception:
|
|
215
|
+
return None
|
|
216
|
+
if parsed is None:
|
|
217
|
+
return None
|
|
218
|
+
lists: dict[str, list[str]] = {}
|
|
219
|
+
raw = parsed.get("lists") if isinstance(parsed, dict) else None
|
|
220
|
+
if isinstance(raw, dict):
|
|
221
|
+
for url, ranges in raw.items():
|
|
222
|
+
entries = [{"ipv6Prefix": p} if ":" in _js.string(p) else {"ipv4Prefix": p} for p in ranges] if isinstance(ranges, list) else None
|
|
223
|
+
prefixes = read_range_list({"prefixes": entries})
|
|
224
|
+
if prefixes:
|
|
225
|
+
lists[url] = prefixes
|
|
226
|
+
at = _js.number(parsed.get("at")) if isinstance(parsed, dict) else 0
|
|
227
|
+
return {"at": at if at == at else 0, "lists": lists}
|