tangram-core 0.3.0__cp310-cp310-manylinux_2_28_aarch64.whl
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.
- tangram_core/App.vue +441 -0
- tangram_core/CommandPalette.vue +200 -0
- tangram_core/HighlightText.vue +32 -0
- tangram_core/__Timeline.vue +300 -0
- tangram_core/__init__.py +5 -0
- tangram_core/__main__.py +331 -0
- tangram_core/_core.cpython-310-aarch64-linux-gnu.so +0 -0
- tangram_core/_core.pyi +38 -0
- tangram_core/api.ts +652 -0
- tangram_core/backend.py +458 -0
- tangram_core/components.ts +2 -0
- tangram_core/config.py +167 -0
- tangram_core/dist-frontend/aggregation-layers.js +521 -0
- tangram_core/dist-frontend/aggregation-layers.js.map +1 -0
- tangram_core/dist-frontend/assets/_commonjsHelpers-CqkleIqs.js +2 -0
- tangram_core/dist-frontend/assets/_commonjsHelpers-CqkleIqs.js.map +1 -0
- tangram_core/dist-frontend/assets/array-utils-flat-BBMak426.js +11 -0
- tangram_core/dist-frontend/assets/array-utils-flat-BBMak426.js.map +1 -0
- tangram_core/dist-frontend/assets/assert-cyW4mg7q.js +3 -0
- tangram_core/dist-frontend/assets/assert-cyW4mg7q.js.map +1 -0
- tangram_core/dist-frontend/assets/b612-latin-400-italic-DePNXA0a.woff +0 -0
- tangram_core/dist-frontend/assets/b612-latin-400-italic-a-4GLPtl.woff2 +0 -0
- tangram_core/dist-frontend/assets/b612-latin-400-normal-CC98FVm_.woff2 +0 -0
- tangram_core/dist-frontend/assets/b612-latin-400-normal-JbZ7xwUX.woff +0 -0
- tangram_core/dist-frontend/assets/b612-latin-700-normal-B_Snq1wd.woff +0 -0
- tangram_core/dist-frontend/assets/b612-latin-700-normal-BinQrnoB.woff2 +0 -0
- tangram_core/dist-frontend/assets/clip-extension-D-rbmFPj.js +26 -0
- tangram_core/dist-frontend/assets/clip-extension-D-rbmFPj.js.map +1 -0
- tangram_core/dist-frontend/assets/color-CUNNsFV-.js +17 -0
- tangram_core/dist-frontend/assets/color-CUNNsFV-.js.map +1 -0
- tangram_core/dist-frontend/assets/cube-geometry-v0HQ793i.js +2 -0
- tangram_core/dist-frontend/assets/cube-geometry-v0HQ793i.js.map +1 -0
- tangram_core/dist-frontend/assets/deep-equal-BTW2ZN6S.js +2 -0
- tangram_core/dist-frontend/assets/deep-equal-BTW2ZN6S.js.map +1 -0
- tangram_core/dist-frontend/assets/fly-to-interpolator-CIXGjOdo.js +2 -0
- tangram_core/dist-frontend/assets/fly-to-interpolator-CIXGjOdo.js.map +1 -0
- tangram_core/dist-frontend/assets/geojson-layer-DgMOQ4Qu.js +1010 -0
- tangram_core/dist-frontend/assets/geojson-layer-DgMOQ4Qu.js.map +1 -0
- tangram_core/dist-frontend/assets/globe-view-Day_n1iB.js +94 -0
- tangram_core/dist-frontend/assets/globe-view-Day_n1iB.js.map +1 -0
- tangram_core/dist-frontend/assets/globe-viewport-tqhQW7C4.js +2 -0
- tangram_core/dist-frontend/assets/globe-viewport-tqhQW7C4.js.map +1 -0
- tangram_core/dist-frontend/assets/image-loader-hHJsndO6.js +2 -0
- tangram_core/dist-frontend/assets/image-loader-hHJsndO6.js.map +1 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-400-normal-DTZQ6lD6.woff2 +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-400-normal-HYADljCo.woff +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-700-normal-ByjKuJjN.woff2 +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-700-normal-DzgUY3Rl.woff +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-ext-400-normal-BaHVOdFB.woff2 +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-ext-400-normal-yvPjCxxx.woff +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-ext-700-normal-D0Kpgs_9.woff2 +0 -0
- tangram_core/dist-frontend/assets/inconsolata-latin-ext-700-normal-Dlt-daqV.woff +0 -0
- tangram_core/dist-frontend/assets/inconsolata-vietnamese-400-normal-ByiM2lek.woff +0 -0
- tangram_core/dist-frontend/assets/inconsolata-vietnamese-400-normal-DfC_iMic.woff2 +0 -0
- tangram_core/dist-frontend/assets/inconsolata-vietnamese-700-normal-DLCFFAUf.woff +0 -0
- tangram_core/dist-frontend/assets/inconsolata-vietnamese-700-normal-DuasYmn8.woff2 +0 -0
- tangram_core/dist-frontend/assets/index-CcogpxdD.js +824 -0
- tangram_core/dist-frontend/assets/index-CcogpxdD.js.map +1 -0
- tangram_core/dist-frontend/assets/index-SSLdizTv.css +1 -0
- tangram_core/dist-frontend/assets/layer-DPcO4AXQ.js +555 -0
- tangram_core/dist-frontend/assets/layer-DPcO4AXQ.js.map +1 -0
- tangram_core/dist-frontend/assets/layer-extension-CYwTXf73.js +2 -0
- tangram_core/dist-frontend/assets/layer-extension-CYwTXf73.js.map +1 -0
- tangram_core/dist-frontend/assets/mesh-layers-wiqredoy.js +1123 -0
- tangram_core/dist-frontend/assets/mesh-layers-wiqredoy.js.map +1 -0
- tangram_core/dist-frontend/assets/orthographic-viewport-B4nCj5tn.js +2 -0
- tangram_core/dist-frontend/assets/orthographic-viewport-B4nCj5tn.js.map +1 -0
- tangram_core/dist-frontend/assets/pick-layers-pass-C-3k0wbN.js +2 -0
- tangram_core/dist-frontend/assets/pick-layers-pass-C-3k0wbN.js.map +1 -0
- tangram_core/dist-frontend/assets/project-BTjD2Imj.js +760 -0
- tangram_core/dist-frontend/assets/project-BTjD2Imj.js.map +1 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-400-italic-4qS3_zkX.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-400-italic-CDK-EZBY.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-400-normal-Bgns473E.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-400-normal-_T2aQlWs.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-500-normal-CvEVpWxD.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-500-normal-s4PklZE0.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-700-normal-9RN-Z7cI.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-700-normal-BGMkBBYx.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-400-italic-C7erd-g8.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-400-italic-DR5R5TWx.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-400-normal-DGo1Ayjq.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-400-normal-WtM1l1qc.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-500-normal-C8FNIdXm.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-500-normal-TLDmfi3Q.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-700-normal-CTXjXnze.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-700-normal-CWPRiRXS.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-400-italic-CR6qj4Z4.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-400-italic-DHRaIs10.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-400-normal-D5vBSIyg.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-400-normal-FabMgVmk.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-500-normal-BIN62cw9.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-500-normal-Hsn-wDIp.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-700-normal-89Up2Xly.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-greek-700-normal-DWMOA2VK.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-400-italic-D_BR-3LG.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-400-italic-om57GXsO.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-400-normal-BICmKrXV.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-400-normal-D2e7XwB1.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-500-normal-3p2daRJW.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-500-normal-Dc9bsamC.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-700-normal-BOl6B_hI.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-700-normal-DRbp0YnP.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-400-italic-BXrkWnoY.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-400-italic-Bhem1d5z.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-400-normal-DT8nEsYA.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-400-normal-OHaX69iP.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-500-normal-CcSTXKtO.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-500-normal-JgPl2bDS.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-700-normal-B004qtqu.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-700-normal-O6H_RRvN.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-400-italic-BwUYFJ2t.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-400-italic-DV8QogUk.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-400-normal-0o1laQ-g.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-400-normal-CPsdS8_S.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-500-normal-G9shSJ2z.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-500-normal-TFWhjk13.woff2 +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-700-normal-BtNeb9D6.woff +0 -0
- tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-700-normal-D35V1G0s.woff2 +0 -0
- tangram_core/dist-frontend/assets/shader-Cbdysp2j.js +843 -0
- tangram_core/dist-frontend/assets/shader-Cbdysp2j.js.map +1 -0
- tangram_core/dist-frontend/assets/solid-polygon-layer-DJFl_7Ca.js +392 -0
- tangram_core/dist-frontend/assets/solid-polygon-layer-DJFl_7Ca.js.map +1 -0
- tangram_core/dist-frontend/assets/tesselator-CENyUZ2p.js +2 -0
- tangram_core/dist-frontend/assets/tesselator-CENyUZ2p.js.map +1 -0
- tangram_core/dist-frontend/assets/webgl-developer-tools-utTNOsNf.js +7 -0
- tangram_core/dist-frontend/assets/webgl-developer-tools-utTNOsNf.js.map +1 -0
- tangram_core/dist-frontend/assets/webgl-device-BYRB-GQX.js +3 -0
- tangram_core/dist-frontend/assets/webgl-device-BYRB-GQX.js.map +1 -0
- tangram_core/dist-frontend/assets/widget-BjgEeHAL.js +2 -0
- tangram_core/dist-frontend/assets/widget-BjgEeHAL.js.map +1 -0
- tangram_core/dist-frontend/core.js +60 -0
- tangram_core/dist-frontend/core.js.map +1 -0
- tangram_core/dist-frontend/extensions.js +609 -0
- tangram_core/dist-frontend/extensions.js.map +1 -0
- tangram_core/dist-frontend/favicon.ico +0 -0
- tangram_core/dist-frontend/favicon.png +0 -0
- tangram_core/dist-frontend/geo-layers.js +115 -0
- tangram_core/dist-frontend/geo-layers.js.map +1 -0
- tangram_core/dist-frontend/index.html +39 -0
- tangram_core/dist-frontend/json.js +3 -0
- tangram_core/dist-frontend/json.js.map +1 -0
- tangram_core/dist-frontend/layers.js +268 -0
- tangram_core/dist-frontend/layers.js.map +1 -0
- tangram_core/dist-frontend/mapbox.js +2 -0
- tangram_core/dist-frontend/mapbox.js.map +1 -0
- tangram_core/dist-frontend/mesh-layers.js +2 -0
- tangram_core/dist-frontend/mesh-layers.js.map +1 -0
- tangram_core/dist-frontend/widgets.js +3 -0
- tangram_core/dist-frontend/widgets.js.map +1 -0
- tangram_core/main.ts +28 -0
- tangram_core/package.json +62 -0
- tangram_core/plugin.py +109 -0
- tangram_core/plugin.ts +47 -0
- tangram_core/redis.py +89 -0
- tangram_core/user.css +114 -0
- tangram_core/utils.ts +143 -0
- tangram_core/vite-plugin-tangram.mjs +155 -0
- tangram_core-0.3.0.dist-info/METADATA +101 -0
- tangram_core-0.3.0.dist-info/RECORD +162 -0
- tangram_core-0.3.0.dist-info/WHEEL +4 -0
- tangram_core-0.3.0.dist-info/entry_points.txt +2 -0
tangram_core/backend.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import importlib.resources
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from functools import partial
|
|
15
|
+
from importlib.metadata import Distribution, PackageNotFoundError
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import (
|
|
18
|
+
TYPE_CHECKING,
|
|
19
|
+
Annotated,
|
|
20
|
+
Any,
|
|
21
|
+
AsyncGenerator,
|
|
22
|
+
Awaitable,
|
|
23
|
+
Callable,
|
|
24
|
+
Iterable,
|
|
25
|
+
TypeAlias,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
import platformdirs
|
|
30
|
+
import redis.asyncio as redis
|
|
31
|
+
import uvicorn
|
|
32
|
+
from fastapi import Depends, FastAPI, Request
|
|
33
|
+
from fastapi.responses import FileResponse, ORJSONResponse
|
|
34
|
+
from fastapi.staticfiles import StaticFiles
|
|
35
|
+
|
|
36
|
+
from .config import (
|
|
37
|
+
CacheEntry,
|
|
38
|
+
Config,
|
|
39
|
+
FrontendChannelConfig,
|
|
40
|
+
FrontendConfig,
|
|
41
|
+
IntoConfig,
|
|
42
|
+
)
|
|
43
|
+
from .plugin import load_plugin, scan_plugins
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from .plugin import Plugin
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# see https://www.starlette.io/lifespan/#lifespan-state
|
|
52
|
+
@dataclass
|
|
53
|
+
class BackendState:
|
|
54
|
+
redis_client: redis.Redis
|
|
55
|
+
http_client: httpx.AsyncClient
|
|
56
|
+
config: Config
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def base_url(self) -> str:
|
|
60
|
+
host = self.config.server.host
|
|
61
|
+
port = self.config.server.port
|
|
62
|
+
if host == "0.0.0.0":
|
|
63
|
+
host = "127.0.0.1"
|
|
64
|
+
return f"http://{host}:{port}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def get_state(request: Request) -> BackendState:
|
|
68
|
+
return request.app.state.backend_state # type: ignore
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
InjectBackendState: TypeAlias = Annotated[BackendState, Depends(get_state)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_distribution_path(dist_name: str) -> Path:
|
|
75
|
+
"""Get the local path of a distribution, handling both editable installs
|
|
76
|
+
(`direct_url.json`) and standard wheel installs.
|
|
77
|
+
|
|
78
|
+
See: https://packaging.python.org/en/latest/specifications/direct-url-data-structure/
|
|
79
|
+
"""
|
|
80
|
+
# always try direct_url.json first (e.g. for the case of `uv sync --all-packages`)
|
|
81
|
+
try:
|
|
82
|
+
dist = Distribution.from_name(dist_name)
|
|
83
|
+
if direct_url_content := dist.read_text("direct_url.json"):
|
|
84
|
+
direct_url_data = json.loads(direct_url_content)
|
|
85
|
+
if (
|
|
86
|
+
(url := direct_url_data.get("url"))
|
|
87
|
+
# url may point to a git or zip archive, but since we only care
|
|
88
|
+
# about local paths, we only handle the file:// scheme here
|
|
89
|
+
and url.startswith("file://")
|
|
90
|
+
):
|
|
91
|
+
parsed = urllib.parse.urlparse(url)
|
|
92
|
+
if os.name == "nt":
|
|
93
|
+
path_str = urllib.request.url2pathname(parsed.path)
|
|
94
|
+
if parsed.netloc and parsed.netloc not in ("", "localhost"):
|
|
95
|
+
path_str = f"//{parsed.netloc}{path_str}"
|
|
96
|
+
path1 = Path(path_str)
|
|
97
|
+
else:
|
|
98
|
+
path1 = Path(urllib.parse.unquote(parsed.path))
|
|
99
|
+
if path1.is_dir():
|
|
100
|
+
return path1
|
|
101
|
+
except (PackageNotFoundError, json.JSONDecodeError, FileNotFoundError):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# fallback in case it was installed via a wheel
|
|
105
|
+
if (trav := importlib.resources.files(dist_name)).is_dir():
|
|
106
|
+
with importlib.resources.as_file(trav) as path2:
|
|
107
|
+
return path2
|
|
108
|
+
raise FileNotFoundError(f"could not find distribution path for {dist_name}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_frontend(plugin: Plugin) -> Path | None:
|
|
112
|
+
if not plugin.frontend_path:
|
|
113
|
+
return None
|
|
114
|
+
return get_distribution_path(plugin.dist_name) / plugin.frontend_path
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load_enabled_plugins(
|
|
118
|
+
config: Config,
|
|
119
|
+
) -> list[Plugin]:
|
|
120
|
+
loaded_plugins = []
|
|
121
|
+
enabled_plugin_names = set(config.core.plugins)
|
|
122
|
+
|
|
123
|
+
for entry_point in scan_plugins():
|
|
124
|
+
# TODO: should we check entry_point.dist.name instead?
|
|
125
|
+
if entry_point.name not in enabled_plugin_names:
|
|
126
|
+
continue
|
|
127
|
+
if (plugin := load_plugin(entry_point)) is not None:
|
|
128
|
+
loaded_plugins.append(plugin)
|
|
129
|
+
|
|
130
|
+
return loaded_plugins
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@asynccontextmanager
|
|
134
|
+
async def lifespan(
|
|
135
|
+
app: FastAPI, backend_state: BackendState, loaded_plugins: Iterable[Plugin]
|
|
136
|
+
) -> AsyncGenerator[None, None]:
|
|
137
|
+
async with AsyncExitStack() as stack:
|
|
138
|
+
for plugin in loaded_plugins:
|
|
139
|
+
if plugin.lifespan:
|
|
140
|
+
logger.info(f"initializing lifespan for {plugin.dist_name}")
|
|
141
|
+
await stack.enter_async_context(plugin.lifespan(backend_state))
|
|
142
|
+
|
|
143
|
+
app.state.backend_state = backend_state
|
|
144
|
+
yield
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def default_cache_dir() -> Path:
|
|
148
|
+
if (xdg_cache := os.environ.get("XDG_CACHE_HOME")) is not None:
|
|
149
|
+
cache_dir = Path(xdg_cache) / "tangram"
|
|
150
|
+
else:
|
|
151
|
+
cache_dir = Path(platformdirs.user_cache_dir(appname="tangram"))
|
|
152
|
+
if not cache_dir.exists():
|
|
153
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
|
|
155
|
+
return cache_dir
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
CACHE_PARAM_PATTERN = re.compile(r"\{(\w+)\}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def make_cache_route_handler(
|
|
162
|
+
entry: CacheEntry, state: BackendState
|
|
163
|
+
) -> Callable[..., Awaitable[FileResponse]]:
|
|
164
|
+
"""
|
|
165
|
+
Factory function that creates a route handler for caching and serving files.
|
|
166
|
+
Dynamically handles URL parameters found in both serve_route and origin.
|
|
167
|
+
|
|
168
|
+
:param entry: Cache entry configuration
|
|
169
|
+
:param state: Backend state with http_client for fetching remote resources
|
|
170
|
+
:returns: Async function that handles the route with dynamic parameters
|
|
171
|
+
"""
|
|
172
|
+
from inspect import Parameter, Signature
|
|
173
|
+
|
|
174
|
+
# Extract parameter names from the serve_route (e.g., {fontstack}, {range})
|
|
175
|
+
params = CACHE_PARAM_PATTERN.findall(entry.serve_route)
|
|
176
|
+
|
|
177
|
+
async def cache_route_handler(**kwargs: str) -> FileResponse:
|
|
178
|
+
if (local_path := entry.local_path) is None:
|
|
179
|
+
local_path = default_cache_dir()
|
|
180
|
+
else:
|
|
181
|
+
local_path = local_path.expanduser()
|
|
182
|
+
|
|
183
|
+
# Build the local file path by replacing parameters
|
|
184
|
+
local_file = local_path
|
|
185
|
+
for param in params:
|
|
186
|
+
if param in kwargs:
|
|
187
|
+
local_file = local_file / kwargs[param]
|
|
188
|
+
|
|
189
|
+
logger.info(f"Serving cached file from {local_file}")
|
|
190
|
+
|
|
191
|
+
if not local_file.exists():
|
|
192
|
+
assert entry.origin is not None
|
|
193
|
+
# Build the remote URL by replacing parameters
|
|
194
|
+
remote_url = entry.origin
|
|
195
|
+
for param, value in kwargs.items():
|
|
196
|
+
remote_url = remote_url.replace(f"{{{param}}}", value)
|
|
197
|
+
|
|
198
|
+
logger.info(f"Downloading from {remote_url} to {local_file}")
|
|
199
|
+
c = await state.http_client.get(remote_url)
|
|
200
|
+
c.raise_for_status()
|
|
201
|
+
local_file.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
local_file.write_bytes(c.content)
|
|
203
|
+
|
|
204
|
+
return FileResponse(path=local_file, media_type=entry.media_type)
|
|
205
|
+
|
|
206
|
+
# Create explicit parameters for the function signature
|
|
207
|
+
sig_params = [
|
|
208
|
+
Parameter(
|
|
209
|
+
name=param,
|
|
210
|
+
kind=Parameter.POSITIONAL_OR_KEYWORD,
|
|
211
|
+
annotation=str,
|
|
212
|
+
)
|
|
213
|
+
for param in params
|
|
214
|
+
]
|
|
215
|
+
cache_route_handler.__signature__ = Signature( # type: ignore
|
|
216
|
+
parameters=sig_params,
|
|
217
|
+
return_annotation=FileResponse,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return cache_route_handler
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def create_app(
|
|
224
|
+
backend_state: BackendState,
|
|
225
|
+
loaded_plugins: Iterable[Plugin],
|
|
226
|
+
) -> FastAPI:
|
|
227
|
+
app = FastAPI(
|
|
228
|
+
lifespan=partial(
|
|
229
|
+
lifespan, backend_state=backend_state, loaded_plugins=loaded_plugins
|
|
230
|
+
),
|
|
231
|
+
default_response_class=ORJSONResponse,
|
|
232
|
+
)
|
|
233
|
+
frontend_plugins = {}
|
|
234
|
+
|
|
235
|
+
for plugin in loaded_plugins:
|
|
236
|
+
for router in plugin.routers:
|
|
237
|
+
app.include_router(router)
|
|
238
|
+
|
|
239
|
+
if (frontend_path_resolved := resolve_frontend(plugin)) is not None:
|
|
240
|
+
app.mount(
|
|
241
|
+
f"/plugins/{plugin.dist_name}",
|
|
242
|
+
StaticFiles(directory=str(frontend_path_resolved)),
|
|
243
|
+
name=plugin.dist_name,
|
|
244
|
+
)
|
|
245
|
+
plugin_json_path = frontend_path_resolved / "plugin.json"
|
|
246
|
+
if plugin_json_path.exists():
|
|
247
|
+
try:
|
|
248
|
+
with plugin_json_path.open("rb") as f:
|
|
249
|
+
plugin_meta = json.load(f)
|
|
250
|
+
|
|
251
|
+
conf_backend = backend_state.config.plugins.get(
|
|
252
|
+
plugin.dist_name, {}
|
|
253
|
+
)
|
|
254
|
+
if to_frontend_conf := plugin.into_frontend_config_function:
|
|
255
|
+
conf_frontend = to_frontend_conf(conf_backend)
|
|
256
|
+
else:
|
|
257
|
+
conf_frontend = conf_backend
|
|
258
|
+
|
|
259
|
+
plugin_meta["config"] = conf_frontend
|
|
260
|
+
frontend_plugins[plugin.dist_name] = plugin_meta
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error(
|
|
263
|
+
f"failed to read plugin.json for {plugin.dist_name}: {e}"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# unlike v0.1 which uses `process.env`, v0.2 *compiles* the js so we no
|
|
267
|
+
# no longer have access to it, so we selectively forward the config.
|
|
268
|
+
@app.get("/config")
|
|
269
|
+
async def get_frontend_config(
|
|
270
|
+
state: Annotated[BackendState, Depends(get_state)],
|
|
271
|
+
) -> FrontendConfig:
|
|
272
|
+
channel_cfg = state.config.channel
|
|
273
|
+
if channel_cfg.public_url:
|
|
274
|
+
channel_url = channel_cfg.public_url
|
|
275
|
+
else:
|
|
276
|
+
# for local/non-proxied setups, user must set a reachable host.
|
|
277
|
+
# '0.0.0.0' is for listening, not connecting.
|
|
278
|
+
host = "localhost" if channel_cfg.host == "0.0.0.0" else channel_cfg.host
|
|
279
|
+
channel_url = f"http://{host}:{channel_cfg.port}"
|
|
280
|
+
|
|
281
|
+
return FrontendConfig(
|
|
282
|
+
channel=FrontendChannelConfig(url=channel_url),
|
|
283
|
+
map=state.config.map,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
@app.get("/manifest.json")
|
|
287
|
+
async def get_manifest() -> ORJSONResponse:
|
|
288
|
+
return ORJSONResponse(content={"plugins": frontend_plugins})
|
|
289
|
+
|
|
290
|
+
# Cache mechanism - MUST be registered BEFORE the catch-all frontend mount
|
|
291
|
+
for cache_entry in backend_state.config.cache.entries:
|
|
292
|
+
logger.info(
|
|
293
|
+
f"caching {cache_entry.origin} to {cache_entry.local_path} "
|
|
294
|
+
f"and serving at {cache_entry.serve_route}"
|
|
295
|
+
)
|
|
296
|
+
route_handler = make_cache_route_handler(cache_entry, backend_state)
|
|
297
|
+
|
|
298
|
+
logger.info(
|
|
299
|
+
f"Registering route: GET {cache_entry.serve_route} with dynamic params"
|
|
300
|
+
)
|
|
301
|
+
app.add_api_route(
|
|
302
|
+
cache_entry.serve_route,
|
|
303
|
+
route_handler,
|
|
304
|
+
methods=["GET"],
|
|
305
|
+
name=f"cache-{cache_entry.serve_route.replace('/', '_')}",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if not (
|
|
309
|
+
frontend_path := get_distribution_path("tangram_core") / "dist-frontend"
|
|
310
|
+
).is_dir():
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f"error: frontend {frontend_path} was not found, "
|
|
313
|
+
"did you run `pnpm i && pnpm run build`?"
|
|
314
|
+
)
|
|
315
|
+
app.mount("/", StaticFiles(directory=str(frontend_path), html=True), name="core")
|
|
316
|
+
|
|
317
|
+
return app
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def run_channel_service(config: Config) -> None:
|
|
321
|
+
from . import _core
|
|
322
|
+
|
|
323
|
+
_core.init_tracing_stderr(config.core.log_level)
|
|
324
|
+
|
|
325
|
+
rust_config = _core.ChannelConfig(
|
|
326
|
+
host=config.channel.host,
|
|
327
|
+
port=config.channel.port,
|
|
328
|
+
redis_url=config.core.redis_url,
|
|
329
|
+
jwt_secret=config.channel.jwt_secret,
|
|
330
|
+
jwt_expiration_secs=config.channel.jwt_expiration_secs,
|
|
331
|
+
id_length=config.channel.id_length,
|
|
332
|
+
)
|
|
333
|
+
await _core.run(rust_config)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class Runtime:
|
|
337
|
+
"""Manages the lifecycle of the Tangram backend, including the
|
|
338
|
+
Uvicorn server, background services, and connection pools (Redis, HTTPX).
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
def __init__(self, config: IntoConfig | None = None) -> None:
|
|
342
|
+
if isinstance(config, (str, bytes, Path, os.PathLike)):
|
|
343
|
+
self.config = Config.from_file(config)
|
|
344
|
+
else:
|
|
345
|
+
self.config = config or Config()
|
|
346
|
+
self._stack = AsyncExitStack()
|
|
347
|
+
self._state: BackendState | None = None
|
|
348
|
+
self._server: uvicorn.Server | None = None
|
|
349
|
+
self._server_task: asyncio.Task[None] | None = None
|
|
350
|
+
self._service_tasks: list[asyncio.Task[None]] = []
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def state(self) -> BackendState:
|
|
354
|
+
if self._state is None:
|
|
355
|
+
raise RuntimeError("runtime is not started, call start() first")
|
|
356
|
+
return self._state
|
|
357
|
+
|
|
358
|
+
async def start(self) -> Runtime:
|
|
359
|
+
"""Starts the backend runtime."""
|
|
360
|
+
if self._state is not None:
|
|
361
|
+
raise RuntimeError("runtime is already started")
|
|
362
|
+
|
|
363
|
+
redis_client = await self._stack.enter_async_context(
|
|
364
|
+
redis.from_url(self.config.core.redis_url) # type: ignore
|
|
365
|
+
)
|
|
366
|
+
http_client = await self._stack.enter_async_context(
|
|
367
|
+
httpx.AsyncClient(http2=True)
|
|
368
|
+
)
|
|
369
|
+
self._state = BackendState(
|
|
370
|
+
redis_client=redis_client,
|
|
371
|
+
http_client=http_client,
|
|
372
|
+
config=self.config,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
loaded_plugins = load_enabled_plugins(self.config)
|
|
376
|
+
app = create_app(self._state, loaded_plugins)
|
|
377
|
+
|
|
378
|
+
server_config = uvicorn.Config(
|
|
379
|
+
app,
|
|
380
|
+
host=self.config.server.host,
|
|
381
|
+
port=self.config.server.port,
|
|
382
|
+
log_config=get_log_config_dict(self.config),
|
|
383
|
+
)
|
|
384
|
+
self._server = uvicorn.Server(server_config)
|
|
385
|
+
|
|
386
|
+
self._service_tasks.append(
|
|
387
|
+
asyncio.create_task(run_channel_service(self.config))
|
|
388
|
+
)
|
|
389
|
+
for plugin in loaded_plugins:
|
|
390
|
+
for _, service_func in sorted(
|
|
391
|
+
plugin.services, key=lambda s: (s[0], s[1].__name__)
|
|
392
|
+
):
|
|
393
|
+
self._service_tasks.append(
|
|
394
|
+
asyncio.create_task(service_func(self._state))
|
|
395
|
+
)
|
|
396
|
+
logger.info(f"started service from plugin: {plugin.dist_name}")
|
|
397
|
+
|
|
398
|
+
self._server_task = asyncio.create_task(self._server.serve())
|
|
399
|
+
|
|
400
|
+
while not self._server.started:
|
|
401
|
+
if self._server_task.done():
|
|
402
|
+
await self._server_task
|
|
403
|
+
await asyncio.sleep(0.1)
|
|
404
|
+
|
|
405
|
+
return self
|
|
406
|
+
|
|
407
|
+
async def wait(self) -> None:
|
|
408
|
+
"""Waits for the server task to complete (e.g. via signal or internal error)."""
|
|
409
|
+
if self._server_task:
|
|
410
|
+
try:
|
|
411
|
+
await self._server_task
|
|
412
|
+
except asyncio.CancelledError:
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
async def stop(self) -> None:
|
|
416
|
+
"""Stops the backend runtime."""
|
|
417
|
+
if self._server and self._server.started:
|
|
418
|
+
self._server.should_exit = True
|
|
419
|
+
if self._server_task:
|
|
420
|
+
try:
|
|
421
|
+
await self._server_task
|
|
422
|
+
except asyncio.CancelledError:
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
for task in self._service_tasks:
|
|
426
|
+
task.cancel()
|
|
427
|
+
if self._service_tasks:
|
|
428
|
+
await asyncio.gather(*self._service_tasks, return_exceptions=True)
|
|
429
|
+
self._service_tasks.clear()
|
|
430
|
+
|
|
431
|
+
await self._stack.aclose()
|
|
432
|
+
self._state = None
|
|
433
|
+
self._server = None
|
|
434
|
+
self._server_task = None
|
|
435
|
+
|
|
436
|
+
async def __aenter__(self) -> Runtime:
|
|
437
|
+
return await self.start()
|
|
438
|
+
|
|
439
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
440
|
+
await self.stop()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def get_log_config_dict(config: Config) -> dict[str, Any]:
|
|
444
|
+
def format_time(dt: datetime) -> str:
|
|
445
|
+
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ ")
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
"version": 1,
|
|
449
|
+
"disable_existing_loggers": False,
|
|
450
|
+
"handlers": {
|
|
451
|
+
"default": {
|
|
452
|
+
"class": "rich.logging.RichHandler",
|
|
453
|
+
"log_time_format": format_time,
|
|
454
|
+
"omit_repeated_times": False,
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
"root": {"handlers": ["default"], "level": config.core.log_level.upper()},
|
|
458
|
+
}
|
tangram_core/config.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from os import PathLike
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal, Protocol, TypeAlias, runtime_checkable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def default_config_file() -> Path:
|
|
12
|
+
import platformdirs
|
|
13
|
+
|
|
14
|
+
if (xdg_config := os.environ.get("XDG_CONFIG_HOME")) is not None:
|
|
15
|
+
config_dir = Path(xdg_config) / "tangram"
|
|
16
|
+
else:
|
|
17
|
+
config_dir = Path(platformdirs.user_config_dir(appname="tangram"))
|
|
18
|
+
if not config_dir.exists():
|
|
19
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
|
|
21
|
+
return Path(config_dir) / "tangram.toml"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class HasTopbarUiConfig(Protocol):
|
|
26
|
+
topbar_order: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class HasSidebarUiConfig(Protocol):
|
|
31
|
+
sidebar_order: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ServerConfig:
|
|
36
|
+
host: str = "127.0.0.1"
|
|
37
|
+
port: int = 2346
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ChannelConfig:
|
|
42
|
+
# TODO: we should make it clear that host:port is for the *backend* to
|
|
43
|
+
# listen on, and not to be confused with the frontend.
|
|
44
|
+
host: str = "127.0.0.1"
|
|
45
|
+
port: int = 2347
|
|
46
|
+
public_url: str | None = None
|
|
47
|
+
jwt_secret: str = "secret"
|
|
48
|
+
jwt_expiration_secs: int = 315360000 # 10 years
|
|
49
|
+
id_length: int = 8
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class UrlConfig:
|
|
54
|
+
url: str
|
|
55
|
+
type: str = "vector"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class SourceSpecification:
|
|
60
|
+
carto: UrlConfig | None = None
|
|
61
|
+
protomaps: UrlConfig | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class StyleSpecification:
|
|
66
|
+
sources: SourceSpecification | None = None
|
|
67
|
+
glyphs: str = "https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf"
|
|
68
|
+
layers: list[Any] | None = None
|
|
69
|
+
version: Literal[8] = 8
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class MapConfig:
|
|
74
|
+
style: str | StyleSpecification = (
|
|
75
|
+
"https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
|
|
76
|
+
)
|
|
77
|
+
attribution: str = (
|
|
78
|
+
'© <a href="https://www.openstreetmap.org/copyright">'
|
|
79
|
+
"OpenStreetMap</a> contributors © "
|
|
80
|
+
'<a href="https://carto.com/attributions">CARTO</a>'
|
|
81
|
+
)
|
|
82
|
+
center_lat: float = 48.0
|
|
83
|
+
center_lon: float = 7.0
|
|
84
|
+
zoom: float = 4
|
|
85
|
+
pitch: float = 0
|
|
86
|
+
bearing: float = 0
|
|
87
|
+
lang: str = "en"
|
|
88
|
+
min_zoom: float = 0
|
|
89
|
+
max_zoom: float = 24
|
|
90
|
+
max_pitch: float = 70
|
|
91
|
+
allow_pitch: bool = True
|
|
92
|
+
allow_bearing: bool = True
|
|
93
|
+
enable_3d: bool = False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class CoreConfig:
|
|
98
|
+
redis_url: str = "redis://127.0.0.1:6379"
|
|
99
|
+
plugins: list[str] = field(default_factory=list)
|
|
100
|
+
log_level: str = "INFO"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class CacheEntry:
|
|
105
|
+
origin: str | None = None
|
|
106
|
+
"""Origin URL. If None, the local file is served directly."""
|
|
107
|
+
local_path: Path | None = None
|
|
108
|
+
"""Local path to cache the file."""
|
|
109
|
+
serve_route: str = ""
|
|
110
|
+
"""Where to serve the file in FastAPI."""
|
|
111
|
+
media_type: str = "application/octet-stream"
|
|
112
|
+
"""Media type for the response."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class CacheConfig:
|
|
117
|
+
entries: list[CacheEntry] = field(default_factory=list)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# do not use with dataclasses yet: it probably wont work for pydantic.TypeAdapter
|
|
121
|
+
# stolen from typeshed. maybe we should apply it everywhere
|
|
122
|
+
StrOrPathLike = str | bytes | PathLike[str] | PathLike[bytes]
|
|
123
|
+
IntoConfig: TypeAlias = "Config | StrOrPathLike"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class Config:
|
|
128
|
+
core: CoreConfig = field(default_factory=CoreConfig)
|
|
129
|
+
server: ServerConfig = field(default_factory=ServerConfig)
|
|
130
|
+
channel: ChannelConfig = field(default_factory=ChannelConfig)
|
|
131
|
+
map: MapConfig = field(default_factory=MapConfig)
|
|
132
|
+
plugins: dict[str, Any] = field(default_factory=dict)
|
|
133
|
+
"""Mapping of plugin name to plugin-specific config."""
|
|
134
|
+
cache: CacheConfig = field(default_factory=CacheConfig)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_file(cls, config_path: StrOrPathLike) -> Config:
|
|
138
|
+
if sys.version_info < (3, 11):
|
|
139
|
+
import tomli as tomllib
|
|
140
|
+
else:
|
|
141
|
+
import tomllib
|
|
142
|
+
from pydantic import TypeAdapter
|
|
143
|
+
|
|
144
|
+
with open(config_path, "rb") as f:
|
|
145
|
+
cfg_data = tomllib.load(f)
|
|
146
|
+
|
|
147
|
+
config_adapter = TypeAdapter(cls)
|
|
148
|
+
config = config_adapter.validate_python(cfg_data)
|
|
149
|
+
return config
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
#
|
|
153
|
+
# when served over reverse proxies, we do not want to simply expose the entire
|
|
154
|
+
# backend config to the frontend. the following structs are used to selectively
|
|
155
|
+
# expose a subset of the config to the frontend.
|
|
156
|
+
#
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class FrontendChannelConfig:
|
|
161
|
+
url: str
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class FrontendConfig:
|
|
166
|
+
channel: FrontendChannelConfig
|
|
167
|
+
map: MapConfig
|