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.
Files changed (162) hide show
  1. tangram_core/App.vue +441 -0
  2. tangram_core/CommandPalette.vue +200 -0
  3. tangram_core/HighlightText.vue +32 -0
  4. tangram_core/__Timeline.vue +300 -0
  5. tangram_core/__init__.py +5 -0
  6. tangram_core/__main__.py +331 -0
  7. tangram_core/_core.cpython-310-aarch64-linux-gnu.so +0 -0
  8. tangram_core/_core.pyi +38 -0
  9. tangram_core/api.ts +652 -0
  10. tangram_core/backend.py +458 -0
  11. tangram_core/components.ts +2 -0
  12. tangram_core/config.py +167 -0
  13. tangram_core/dist-frontend/aggregation-layers.js +521 -0
  14. tangram_core/dist-frontend/aggregation-layers.js.map +1 -0
  15. tangram_core/dist-frontend/assets/_commonjsHelpers-CqkleIqs.js +2 -0
  16. tangram_core/dist-frontend/assets/_commonjsHelpers-CqkleIqs.js.map +1 -0
  17. tangram_core/dist-frontend/assets/array-utils-flat-BBMak426.js +11 -0
  18. tangram_core/dist-frontend/assets/array-utils-flat-BBMak426.js.map +1 -0
  19. tangram_core/dist-frontend/assets/assert-cyW4mg7q.js +3 -0
  20. tangram_core/dist-frontend/assets/assert-cyW4mg7q.js.map +1 -0
  21. tangram_core/dist-frontend/assets/b612-latin-400-italic-DePNXA0a.woff +0 -0
  22. tangram_core/dist-frontend/assets/b612-latin-400-italic-a-4GLPtl.woff2 +0 -0
  23. tangram_core/dist-frontend/assets/b612-latin-400-normal-CC98FVm_.woff2 +0 -0
  24. tangram_core/dist-frontend/assets/b612-latin-400-normal-JbZ7xwUX.woff +0 -0
  25. tangram_core/dist-frontend/assets/b612-latin-700-normal-B_Snq1wd.woff +0 -0
  26. tangram_core/dist-frontend/assets/b612-latin-700-normal-BinQrnoB.woff2 +0 -0
  27. tangram_core/dist-frontend/assets/clip-extension-D-rbmFPj.js +26 -0
  28. tangram_core/dist-frontend/assets/clip-extension-D-rbmFPj.js.map +1 -0
  29. tangram_core/dist-frontend/assets/color-CUNNsFV-.js +17 -0
  30. tangram_core/dist-frontend/assets/color-CUNNsFV-.js.map +1 -0
  31. tangram_core/dist-frontend/assets/cube-geometry-v0HQ793i.js +2 -0
  32. tangram_core/dist-frontend/assets/cube-geometry-v0HQ793i.js.map +1 -0
  33. tangram_core/dist-frontend/assets/deep-equal-BTW2ZN6S.js +2 -0
  34. tangram_core/dist-frontend/assets/deep-equal-BTW2ZN6S.js.map +1 -0
  35. tangram_core/dist-frontend/assets/fly-to-interpolator-CIXGjOdo.js +2 -0
  36. tangram_core/dist-frontend/assets/fly-to-interpolator-CIXGjOdo.js.map +1 -0
  37. tangram_core/dist-frontend/assets/geojson-layer-DgMOQ4Qu.js +1010 -0
  38. tangram_core/dist-frontend/assets/geojson-layer-DgMOQ4Qu.js.map +1 -0
  39. tangram_core/dist-frontend/assets/globe-view-Day_n1iB.js +94 -0
  40. tangram_core/dist-frontend/assets/globe-view-Day_n1iB.js.map +1 -0
  41. tangram_core/dist-frontend/assets/globe-viewport-tqhQW7C4.js +2 -0
  42. tangram_core/dist-frontend/assets/globe-viewport-tqhQW7C4.js.map +1 -0
  43. tangram_core/dist-frontend/assets/image-loader-hHJsndO6.js +2 -0
  44. tangram_core/dist-frontend/assets/image-loader-hHJsndO6.js.map +1 -0
  45. tangram_core/dist-frontend/assets/inconsolata-latin-400-normal-DTZQ6lD6.woff2 +0 -0
  46. tangram_core/dist-frontend/assets/inconsolata-latin-400-normal-HYADljCo.woff +0 -0
  47. tangram_core/dist-frontend/assets/inconsolata-latin-700-normal-ByjKuJjN.woff2 +0 -0
  48. tangram_core/dist-frontend/assets/inconsolata-latin-700-normal-DzgUY3Rl.woff +0 -0
  49. tangram_core/dist-frontend/assets/inconsolata-latin-ext-400-normal-BaHVOdFB.woff2 +0 -0
  50. tangram_core/dist-frontend/assets/inconsolata-latin-ext-400-normal-yvPjCxxx.woff +0 -0
  51. tangram_core/dist-frontend/assets/inconsolata-latin-ext-700-normal-D0Kpgs_9.woff2 +0 -0
  52. tangram_core/dist-frontend/assets/inconsolata-latin-ext-700-normal-Dlt-daqV.woff +0 -0
  53. tangram_core/dist-frontend/assets/inconsolata-vietnamese-400-normal-ByiM2lek.woff +0 -0
  54. tangram_core/dist-frontend/assets/inconsolata-vietnamese-400-normal-DfC_iMic.woff2 +0 -0
  55. tangram_core/dist-frontend/assets/inconsolata-vietnamese-700-normal-DLCFFAUf.woff +0 -0
  56. tangram_core/dist-frontend/assets/inconsolata-vietnamese-700-normal-DuasYmn8.woff2 +0 -0
  57. tangram_core/dist-frontend/assets/index-CcogpxdD.js +824 -0
  58. tangram_core/dist-frontend/assets/index-CcogpxdD.js.map +1 -0
  59. tangram_core/dist-frontend/assets/index-SSLdizTv.css +1 -0
  60. tangram_core/dist-frontend/assets/layer-DPcO4AXQ.js +555 -0
  61. tangram_core/dist-frontend/assets/layer-DPcO4AXQ.js.map +1 -0
  62. tangram_core/dist-frontend/assets/layer-extension-CYwTXf73.js +2 -0
  63. tangram_core/dist-frontend/assets/layer-extension-CYwTXf73.js.map +1 -0
  64. tangram_core/dist-frontend/assets/mesh-layers-wiqredoy.js +1123 -0
  65. tangram_core/dist-frontend/assets/mesh-layers-wiqredoy.js.map +1 -0
  66. tangram_core/dist-frontend/assets/orthographic-viewport-B4nCj5tn.js +2 -0
  67. tangram_core/dist-frontend/assets/orthographic-viewport-B4nCj5tn.js.map +1 -0
  68. tangram_core/dist-frontend/assets/pick-layers-pass-C-3k0wbN.js +2 -0
  69. tangram_core/dist-frontend/assets/pick-layers-pass-C-3k0wbN.js.map +1 -0
  70. tangram_core/dist-frontend/assets/project-BTjD2Imj.js +760 -0
  71. tangram_core/dist-frontend/assets/project-BTjD2Imj.js.map +1 -0
  72. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-400-italic-4qS3_zkX.woff2 +0 -0
  73. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-400-italic-CDK-EZBY.woff +0 -0
  74. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-400-normal-Bgns473E.woff +0 -0
  75. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-400-normal-_T2aQlWs.woff2 +0 -0
  76. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-500-normal-CvEVpWxD.woff +0 -0
  77. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-500-normal-s4PklZE0.woff2 +0 -0
  78. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-700-normal-9RN-Z7cI.woff2 +0 -0
  79. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-700-normal-BGMkBBYx.woff +0 -0
  80. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-400-italic-C7erd-g8.woff +0 -0
  81. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-400-italic-DR5R5TWx.woff2 +0 -0
  82. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-400-normal-DGo1Ayjq.woff2 +0 -0
  83. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-400-normal-WtM1l1qc.woff +0 -0
  84. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-500-normal-C8FNIdXm.woff2 +0 -0
  85. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-500-normal-TLDmfi3Q.woff +0 -0
  86. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-700-normal-CTXjXnze.woff2 +0 -0
  87. tangram_core/dist-frontend/assets/roboto-condensed-cyrillic-ext-700-normal-CWPRiRXS.woff +0 -0
  88. tangram_core/dist-frontend/assets/roboto-condensed-greek-400-italic-CR6qj4Z4.woff2 +0 -0
  89. tangram_core/dist-frontend/assets/roboto-condensed-greek-400-italic-DHRaIs10.woff +0 -0
  90. tangram_core/dist-frontend/assets/roboto-condensed-greek-400-normal-D5vBSIyg.woff2 +0 -0
  91. tangram_core/dist-frontend/assets/roboto-condensed-greek-400-normal-FabMgVmk.woff +0 -0
  92. tangram_core/dist-frontend/assets/roboto-condensed-greek-500-normal-BIN62cw9.woff +0 -0
  93. tangram_core/dist-frontend/assets/roboto-condensed-greek-500-normal-Hsn-wDIp.woff2 +0 -0
  94. tangram_core/dist-frontend/assets/roboto-condensed-greek-700-normal-89Up2Xly.woff +0 -0
  95. tangram_core/dist-frontend/assets/roboto-condensed-greek-700-normal-DWMOA2VK.woff2 +0 -0
  96. tangram_core/dist-frontend/assets/roboto-condensed-latin-400-italic-D_BR-3LG.woff2 +0 -0
  97. tangram_core/dist-frontend/assets/roboto-condensed-latin-400-italic-om57GXsO.woff +0 -0
  98. tangram_core/dist-frontend/assets/roboto-condensed-latin-400-normal-BICmKrXV.woff2 +0 -0
  99. tangram_core/dist-frontend/assets/roboto-condensed-latin-400-normal-D2e7XwB1.woff +0 -0
  100. tangram_core/dist-frontend/assets/roboto-condensed-latin-500-normal-3p2daRJW.woff2 +0 -0
  101. tangram_core/dist-frontend/assets/roboto-condensed-latin-500-normal-Dc9bsamC.woff +0 -0
  102. tangram_core/dist-frontend/assets/roboto-condensed-latin-700-normal-BOl6B_hI.woff +0 -0
  103. tangram_core/dist-frontend/assets/roboto-condensed-latin-700-normal-DRbp0YnP.woff2 +0 -0
  104. tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-400-italic-BXrkWnoY.woff +0 -0
  105. tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-400-italic-Bhem1d5z.woff2 +0 -0
  106. tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-400-normal-DT8nEsYA.woff +0 -0
  107. tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-400-normal-OHaX69iP.woff2 +0 -0
  108. tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-500-normal-CcSTXKtO.woff2 +0 -0
  109. tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-500-normal-JgPl2bDS.woff +0 -0
  110. tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-700-normal-B004qtqu.woff2 +0 -0
  111. tangram_core/dist-frontend/assets/roboto-condensed-latin-ext-700-normal-O6H_RRvN.woff +0 -0
  112. tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-400-italic-BwUYFJ2t.woff2 +0 -0
  113. tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-400-italic-DV8QogUk.woff +0 -0
  114. tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-400-normal-0o1laQ-g.woff2 +0 -0
  115. tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-400-normal-CPsdS8_S.woff +0 -0
  116. tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-500-normal-G9shSJ2z.woff +0 -0
  117. tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-500-normal-TFWhjk13.woff2 +0 -0
  118. tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-700-normal-BtNeb9D6.woff +0 -0
  119. tangram_core/dist-frontend/assets/roboto-condensed-vietnamese-700-normal-D35V1G0s.woff2 +0 -0
  120. tangram_core/dist-frontend/assets/shader-Cbdysp2j.js +843 -0
  121. tangram_core/dist-frontend/assets/shader-Cbdysp2j.js.map +1 -0
  122. tangram_core/dist-frontend/assets/solid-polygon-layer-DJFl_7Ca.js +392 -0
  123. tangram_core/dist-frontend/assets/solid-polygon-layer-DJFl_7Ca.js.map +1 -0
  124. tangram_core/dist-frontend/assets/tesselator-CENyUZ2p.js +2 -0
  125. tangram_core/dist-frontend/assets/tesselator-CENyUZ2p.js.map +1 -0
  126. tangram_core/dist-frontend/assets/webgl-developer-tools-utTNOsNf.js +7 -0
  127. tangram_core/dist-frontend/assets/webgl-developer-tools-utTNOsNf.js.map +1 -0
  128. tangram_core/dist-frontend/assets/webgl-device-BYRB-GQX.js +3 -0
  129. tangram_core/dist-frontend/assets/webgl-device-BYRB-GQX.js.map +1 -0
  130. tangram_core/dist-frontend/assets/widget-BjgEeHAL.js +2 -0
  131. tangram_core/dist-frontend/assets/widget-BjgEeHAL.js.map +1 -0
  132. tangram_core/dist-frontend/core.js +60 -0
  133. tangram_core/dist-frontend/core.js.map +1 -0
  134. tangram_core/dist-frontend/extensions.js +609 -0
  135. tangram_core/dist-frontend/extensions.js.map +1 -0
  136. tangram_core/dist-frontend/favicon.ico +0 -0
  137. tangram_core/dist-frontend/favicon.png +0 -0
  138. tangram_core/dist-frontend/geo-layers.js +115 -0
  139. tangram_core/dist-frontend/geo-layers.js.map +1 -0
  140. tangram_core/dist-frontend/index.html +39 -0
  141. tangram_core/dist-frontend/json.js +3 -0
  142. tangram_core/dist-frontend/json.js.map +1 -0
  143. tangram_core/dist-frontend/layers.js +268 -0
  144. tangram_core/dist-frontend/layers.js.map +1 -0
  145. tangram_core/dist-frontend/mapbox.js +2 -0
  146. tangram_core/dist-frontend/mapbox.js.map +1 -0
  147. tangram_core/dist-frontend/mesh-layers.js +2 -0
  148. tangram_core/dist-frontend/mesh-layers.js.map +1 -0
  149. tangram_core/dist-frontend/widgets.js +3 -0
  150. tangram_core/dist-frontend/widgets.js.map +1 -0
  151. tangram_core/main.ts +28 -0
  152. tangram_core/package.json +62 -0
  153. tangram_core/plugin.py +109 -0
  154. tangram_core/plugin.ts +47 -0
  155. tangram_core/redis.py +89 -0
  156. tangram_core/user.css +114 -0
  157. tangram_core/utils.ts +143 -0
  158. tangram_core/vite-plugin-tangram.mjs +155 -0
  159. tangram_core-0.3.0.dist-info/METADATA +101 -0
  160. tangram_core-0.3.0.dist-info/RECORD +162 -0
  161. tangram_core-0.3.0.dist-info/WHEEL +4 -0
  162. tangram_core-0.3.0.dist-info/entry_points.txt +2 -0
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ // TODO: expand this file as we standardise styles for buttons and toggles
2
+ export { default as HighlightText } from "./HighlightText.vue";
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
+ '&copy; <a href="https://www.openstreetmap.org/copyright">'
79
+ "OpenStreetMap</a> contributors &copy; "
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