solara-enterprise 1.45.0__py3-none-any.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.
- solara_enterprise/__init__.py +3 -0
- solara_enterprise/auth/__init__.py +13 -0
- solara_enterprise/auth/components.py +118 -0
- solara_enterprise/auth/flask.py +119 -0
- solara_enterprise/auth/middleware.py +127 -0
- solara_enterprise/auth/starlette.py +118 -0
- solara_enterprise/auth/utils.py +36 -0
- solara_enterprise/cache/__init__.py +3 -0
- solara_enterprise/cache/base.py +64 -0
- solara_enterprise/cache/disk.py +44 -0
- solara_enterprise/cache/memory_size.py +29 -0
- solara_enterprise/cache/multi_level.py +25 -0
- solara_enterprise/cache/redis.py +25 -0
- solara_enterprise/license.py +17 -0
- solara_enterprise/search/__init__.py +0 -0
- solara_enterprise/search/index.py +89 -0
- solara_enterprise/search/search.py +25 -0
- solara_enterprise/search/search.vue +170 -0
- solara_enterprise/ssg.py +249 -0
- solara_enterprise-1.45.0.dist-info/METADATA +27 -0
- solara_enterprise-1.45.0.dist-info/RECORD +23 -0
- solara_enterprise-1.45.0.dist-info/WHEEL +4 -0
- solara_enterprise-1.45.0.dist-info/licenses/LICENSE +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import ChainMap
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MultiLevel(ChainMap):
|
|
5
|
+
"""Use multiple caches, where we assume the first is the fastest"""
|
|
6
|
+
|
|
7
|
+
def __getitem__(self, key):
|
|
8
|
+
for level, mapping in enumerate(self.maps):
|
|
9
|
+
try:
|
|
10
|
+
value = mapping[key]
|
|
11
|
+
# write back to lower levels
|
|
12
|
+
for i in range(level):
|
|
13
|
+
self.maps[i][key] = value
|
|
14
|
+
return value
|
|
15
|
+
except KeyError:
|
|
16
|
+
pass
|
|
17
|
+
return self.__missing__(key)
|
|
18
|
+
|
|
19
|
+
def __setitem__(self, key, value):
|
|
20
|
+
for cache in self.maps:
|
|
21
|
+
cache[key] = value
|
|
22
|
+
|
|
23
|
+
def __delitem__(self, key):
|
|
24
|
+
for cache in self.maps:
|
|
25
|
+
del cache[key]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import Any, Callable, Optional
|
|
2
|
+
|
|
3
|
+
import redis
|
|
4
|
+
import solara.settings
|
|
5
|
+
|
|
6
|
+
from solara_enterprise.cache.base import Base, make_key
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Redis(Base):
|
|
10
|
+
"""Wraps a client such that the values are pickled/unpickled"""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self, client: Optional[redis.Redis] = None, clear=solara.settings.cache.clear, prefix=b"solara:cache:", make_key: Callable[[Any], bytes] = make_key
|
|
14
|
+
):
|
|
15
|
+
self.client = client or redis.Redis()
|
|
16
|
+
super().__init__(self.client, prefix=prefix, clear=clear, make_key=make_key)
|
|
17
|
+
|
|
18
|
+
def clear(self):
|
|
19
|
+
with self.client.lock(b"lock:" + self.prefix):
|
|
20
|
+
keys = self.keys()
|
|
21
|
+
for key in keys:
|
|
22
|
+
del self[key]
|
|
23
|
+
|
|
24
|
+
def keys(self):
|
|
25
|
+
return self.client.keys(self.prefix + b"*")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
from rich import print
|
|
5
|
+
|
|
6
|
+
warned: Dict[str, bool] = {}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check(name):
|
|
10
|
+
if name in warned:
|
|
11
|
+
return
|
|
12
|
+
warned[name] = True
|
|
13
|
+
print( # noqa: T201
|
|
14
|
+
f"[bold yellow]Using the enterprise {name} feature requires a license, unless used for non-commerical use. "
|
|
15
|
+
"Please contact us at contact@solara.dev to get a license.",
|
|
16
|
+
file=sys.stderr,
|
|
17
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, List
|
|
4
|
+
|
|
5
|
+
import solara
|
|
6
|
+
from rich import print as rprint
|
|
7
|
+
from solara.server import settings
|
|
8
|
+
from typing_extensions import TypedDict
|
|
9
|
+
|
|
10
|
+
from .. import license
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DocumentData(TypedDict):
|
|
14
|
+
id: str
|
|
15
|
+
text: str
|
|
16
|
+
location: str
|
|
17
|
+
title: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_index(base_url: str):
|
|
21
|
+
license.check("search")
|
|
22
|
+
import solara.server.app
|
|
23
|
+
import solara.server.kernel
|
|
24
|
+
|
|
25
|
+
build_path = settings.ssg.build_path
|
|
26
|
+
assert build_path is not None
|
|
27
|
+
app_script = solara.server.app.apps["__default__"]
|
|
28
|
+
rprint(f"Building search index for {app_script.name} by using {build_path}")
|
|
29
|
+
routes = app_script.routes
|
|
30
|
+
|
|
31
|
+
documents: List[DocumentData] = []
|
|
32
|
+
|
|
33
|
+
for route in routes:
|
|
34
|
+
add_document(f"{base_url}/", route, build_path, documents)
|
|
35
|
+
index_file = app_script.directory.parent / "assets" / "search.json"
|
|
36
|
+
rprint("Writing search index to", index_file)
|
|
37
|
+
with open(index_file, "w") as f:
|
|
38
|
+
json.dump(documents, f, indent=4)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def add_document(base_url: str, route: solara.Route, build_path: Path, documents: List[DocumentData]):
|
|
42
|
+
url = base_url + (route.path if route.path != "/" else "")
|
|
43
|
+
if not route.children:
|
|
44
|
+
path = build_path / ("index.html" if route.path == "/" else route.path + ".html")
|
|
45
|
+
rprint("Processing", path)
|
|
46
|
+
from bs4 import BeautifulSoup # , Tag
|
|
47
|
+
|
|
48
|
+
if not path.exists():
|
|
49
|
+
rprint(f"Warning: {path} does not exist")
|
|
50
|
+
else:
|
|
51
|
+
soup = BeautifulSoup(path.read_text("utf8"), "html.parser")
|
|
52
|
+
node = soup.find(class_="solara-page-content-search")
|
|
53
|
+
|
|
54
|
+
if node is None:
|
|
55
|
+
rprint(f"Warning: {path} has no solara-page-content-search")
|
|
56
|
+
else:
|
|
57
|
+
# split by h1 and h2
|
|
58
|
+
parts: List[List[Any]] = [[]]
|
|
59
|
+
ids = []
|
|
60
|
+
titles = [soup.title.string if soup.title else ""]
|
|
61
|
+
current = parts[-1]
|
|
62
|
+
# remove invisible title elements
|
|
63
|
+
for el in node.find_all("span", attrs={"style": "display: none;"}):
|
|
64
|
+
el.string = ""
|
|
65
|
+
for el in node.descendants:
|
|
66
|
+
if el.name == "h1" or el.name == "h2":
|
|
67
|
+
ids.append(el.get("id"))
|
|
68
|
+
parts.append([])
|
|
69
|
+
titles.append(el.string)
|
|
70
|
+
current = parts[-1]
|
|
71
|
+
else:
|
|
72
|
+
current.append(el)
|
|
73
|
+
# join the next node
|
|
74
|
+
text = ""
|
|
75
|
+
for i, part in enumerate(parts):
|
|
76
|
+
for el in part:
|
|
77
|
+
if el.string:
|
|
78
|
+
text += el.string.strip() + " "
|
|
79
|
+
# texts.append("")
|
|
80
|
+
id = ids[i]
|
|
81
|
+
location = f"{url}#{id}" if id else url
|
|
82
|
+
text = text.strip()
|
|
83
|
+
title = titles[i].strip()
|
|
84
|
+
if title != text:
|
|
85
|
+
documents.append({"id": id, "text": text, "location": location, "title": title})
|
|
86
|
+
text = ""
|
|
87
|
+
# documents.append({"id": url, "html": str(node), "location": url})
|
|
88
|
+
for child in route.children:
|
|
89
|
+
add_document(url + "/", child, build_path / Path(route.path), documents)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import ipyvue
|
|
2
|
+
import solara
|
|
3
|
+
import traitlets
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SearchWidget(ipyvue.VueTemplate):
|
|
7
|
+
template_file = (__file__, "search.vue")
|
|
8
|
+
forceUpdateList = traitlets.Int(0).tag(sync=True)
|
|
9
|
+
item = traitlets.Any().tag(sync=True)
|
|
10
|
+
query = traitlets.Unicode("", allow_none=True).tag(sync=True)
|
|
11
|
+
search_open = traitlets.Bool(False).tag(sync=True)
|
|
12
|
+
failed = traitlets.Bool(False).tag(sync=True)
|
|
13
|
+
cdn = traitlets.Unicode(None, allow_none=True).tag(sync=True)
|
|
14
|
+
|
|
15
|
+
@traitlets.default("cdn")
|
|
16
|
+
def _cdn(self):
|
|
17
|
+
import solara.settings
|
|
18
|
+
|
|
19
|
+
if not solara.settings.assets.proxy:
|
|
20
|
+
return solara.settings.assets.cdn
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@solara.component
|
|
24
|
+
def Search():
|
|
25
|
+
return SearchWidget.element()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<v-menu v-model="search_open" offset-y>
|
|
4
|
+
<template v-slot:activator="on">
|
|
5
|
+
<v-text-field prepend-icon="mdi-magnify" v-model="query" return-object append-icon="" no-filter
|
|
6
|
+
clearable @click="onClick" hide-details class="d-none d-md-flex">
|
|
7
|
+
</v-text-field>
|
|
8
|
+
|
|
9
|
+
</template>
|
|
10
|
+
<v-card class="solara-search-menu">
|
|
11
|
+
<div v-if="failed">
|
|
12
|
+
<v-alert type="error">
|
|
13
|
+
Failed to load search index, maybe the server is still indexing.
|
|
14
|
+
<v-btn text @click="fetchData">Retry</v-btn>
|
|
15
|
+
</v-alert>
|
|
16
|
+
</div>
|
|
17
|
+
<v-list class="pa-3" :key="forceUpdateList" v-if="!failed">
|
|
18
|
+
<v-list-item v-for="item in items.slice(0, 20)" v-if="items" @click="onClickItem(item)">
|
|
19
|
+
<v-list-item-content class="solara-search-list-item">
|
|
20
|
+
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
|
21
|
+
<v-list-item-subtitle>{{ item.location }}</v-list-item-subtitle>
|
|
22
|
+
<v-list-item-subtitle>{{ item.text }}</v-list-item-subtitle>
|
|
23
|
+
</v-list-item-content>
|
|
24
|
+
</v-list-item>
|
|
25
|
+
<v-list-item v-if="items.length > 20">
|
|
26
|
+
<v-list-item-content>
|
|
27
|
+
And {{ items.length - 20 }} more pages.
|
|
28
|
+
</v-list-item-content>
|
|
29
|
+
|
|
30
|
+
</v-list-item>
|
|
31
|
+
<v-list-item v-if="items.length == 0">
|
|
32
|
+
No search results found.
|
|
33
|
+
</v-list-item>
|
|
34
|
+
</v-list>
|
|
35
|
+
</v-card>
|
|
36
|
+
</v-menu>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
39
|
+
<script>
|
|
40
|
+
module.exports = {
|
|
41
|
+
created() {
|
|
42
|
+
this.items = [];
|
|
43
|
+
},
|
|
44
|
+
async mounted() {
|
|
45
|
+
window.search = this;
|
|
46
|
+
await this.loadRequire();
|
|
47
|
+
this.lunr = (await this.import([`${this.getCdn()}/lunr/lunr.js`]))[0];
|
|
48
|
+
this.fetchData();
|
|
49
|
+
window.search = this;
|
|
50
|
+
|
|
51
|
+
},
|
|
52
|
+
watch: {
|
|
53
|
+
query(value) {
|
|
54
|
+
this.search_open = true;
|
|
55
|
+
this.search();
|
|
56
|
+
},
|
|
57
|
+
item(value) {
|
|
58
|
+
solara.router.push(value.location);
|
|
59
|
+
this.$nextTick(() => {
|
|
60
|
+
// we cannot do this directly it seems
|
|
61
|
+
// this.query = "";
|
|
62
|
+
})
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
methods: {
|
|
66
|
+
async fetchData() {
|
|
67
|
+
const url = `${window.solara.rootPath}/static/assets/search.json`
|
|
68
|
+
let documents = [];
|
|
69
|
+
try {
|
|
70
|
+
documents = await (await fetch(url)).json();
|
|
71
|
+
this.failed = false;
|
|
72
|
+
} catch (e) {
|
|
73
|
+
this.failed = true;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.documents = {}
|
|
78
|
+
documents.forEach((document) => {
|
|
79
|
+
this.documents[document.location] = document;
|
|
80
|
+
})
|
|
81
|
+
this.idx = this.lunr(function () {
|
|
82
|
+
this.ref('location')
|
|
83
|
+
this.field('title')
|
|
84
|
+
|
|
85
|
+
this.field('text')
|
|
86
|
+
documents.forEach(function (doc) {
|
|
87
|
+
this.add(doc)
|
|
88
|
+
}, this)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
},
|
|
92
|
+
onClickItem(item) {
|
|
93
|
+
// console.log(item)
|
|
94
|
+
this.item = item;
|
|
95
|
+
},
|
|
96
|
+
onClick() {
|
|
97
|
+
this.search_open = true;
|
|
98
|
+
},
|
|
99
|
+
search() {
|
|
100
|
+
if (this.idx) {
|
|
101
|
+
const searchResult = this.idx.search(this.query || "");
|
|
102
|
+
items = []
|
|
103
|
+
searchResult.forEach((item) => {
|
|
104
|
+
items.push(this.documents[item.ref])
|
|
105
|
+
})
|
|
106
|
+
this.items = items;
|
|
107
|
+
// items is not reactive, so use this proxy
|
|
108
|
+
this.forceUpdateList += 1
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
import(deps) {
|
|
112
|
+
return this.loadRequire().then(
|
|
113
|
+
() => {
|
|
114
|
+
if (window.jupyterVue) {
|
|
115
|
+
// in jupyterlab, we take Vue from ipyvue/jupyterVue
|
|
116
|
+
define("vue", [], () => window.jupyterVue.Vue);
|
|
117
|
+
} else {
|
|
118
|
+
define("vue", ['jupyter-vue'], jupyterVue => jupyterVue.Vue);
|
|
119
|
+
}
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
requirejs(deps, (...modules) => resolve(modules));
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
},
|
|
126
|
+
loadRequire() {
|
|
127
|
+
/* Needed in lab */
|
|
128
|
+
if (window.requirejs) {
|
|
129
|
+
console.log('require found');
|
|
130
|
+
return Promise.resolve()
|
|
131
|
+
}
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const script = document.createElement('script');
|
|
134
|
+
script.src = `${this.getCdn()}/requirejs@2.3.6/require.js`;
|
|
135
|
+
script.onload = resolve;
|
|
136
|
+
script.onerror = reject;
|
|
137
|
+
document.head.appendChild(script);
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
getJupyterBaseUrl() {
|
|
141
|
+
// if base url is set, we use ./ for relative paths compared to the base url
|
|
142
|
+
if (document.getElementsByTagName("base").length) {
|
|
143
|
+
return document.baseURI;
|
|
144
|
+
}
|
|
145
|
+
const labConfigData = document.getElementById('jupyter-config-data');
|
|
146
|
+
if (labConfigData) {
|
|
147
|
+
/* lab and Voila */
|
|
148
|
+
return JSON.parse(labConfigData.textContent).baseUrl;
|
|
149
|
+
}
|
|
150
|
+
let base = document.body.dataset.baseUrl || document.baseURI;
|
|
151
|
+
if (!base.endsWith('/')) {
|
|
152
|
+
base += '/';
|
|
153
|
+
}
|
|
154
|
+
return base
|
|
155
|
+
},
|
|
156
|
+
getCdn() {
|
|
157
|
+
return this.cdn || (window.solara ? window.solara.cdn : `${this.getJupyterBaseUrl()}_solara/cdn`);
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
</script>
|
|
162
|
+
<style id="solara-search">
|
|
163
|
+
.solara-search-list-item {
|
|
164
|
+
max-width: 400px;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.solara-search-menu {
|
|
168
|
+
max-height: 80vh;
|
|
169
|
+
}
|
|
170
|
+
</style>
|
solara_enterprise/ssg.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import concurrent.futures.thread
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import typing
|
|
6
|
+
import urllib
|
|
7
|
+
import weakref
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
import solara
|
|
12
|
+
from rich import print as rprint
|
|
13
|
+
from solara.server import settings
|
|
14
|
+
from typing_extensions import TypedDict
|
|
15
|
+
|
|
16
|
+
from . import license
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("solara.server.ssg")
|
|
19
|
+
|
|
20
|
+
if typing.TYPE_CHECKING:
|
|
21
|
+
import playwright.sync_api
|
|
22
|
+
import playwright.sync_api._context_manager
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Playwright(threading.local):
|
|
26
|
+
browser: Optional["playwright.sync_api.Browser"] = None
|
|
27
|
+
sync_playwright: Optional["playwright.sync_api.Playwright"] = None
|
|
28
|
+
context_manager: Optional["playwright.sync_api._context_manager.PlaywrightContextManager"] = None
|
|
29
|
+
page: Optional["playwright.sync_api.Page"] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
pw = Playwright()
|
|
33
|
+
_used: List[Tuple["playwright.sync_api.Browser", "playwright.sync_api._context_manager.PlaywrightContextManager"]] = []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SSGData(TypedDict):
|
|
37
|
+
title: str
|
|
38
|
+
html: str
|
|
39
|
+
styles: List[str]
|
|
40
|
+
metas: List[str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_playwright():
|
|
44
|
+
if hasattr(pw, "browser") and pw.browser is not None:
|
|
45
|
+
return pw
|
|
46
|
+
from playwright.sync_api import sync_playwright
|
|
47
|
+
|
|
48
|
+
pw.number = 42
|
|
49
|
+
pw.context_manager = sync_playwright()
|
|
50
|
+
pw.sync_playwright = pw.context_manager.start()
|
|
51
|
+
|
|
52
|
+
pw.browser = pw.sync_playwright.chromium.launch(headless=not settings.ssg.headed)
|
|
53
|
+
pw.page = pw.browser.new_page()
|
|
54
|
+
_used.append((pw.browser, pw.context_manager))
|
|
55
|
+
return pw
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _worker_with_cleanup(*args, **kwargs):
|
|
59
|
+
try:
|
|
60
|
+
concurrent.futures.thread._worker(*args, **kwargs)
|
|
61
|
+
finally:
|
|
62
|
+
pw = _get_playwright()
|
|
63
|
+
pw.browser.close()
|
|
64
|
+
pw.context_manager.__exit__(None, None, None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CleanupThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
|
|
68
|
+
def _adjust_thread_count(self):
|
|
69
|
+
# copy of the original code with _worker replaced
|
|
70
|
+
# if idle threads are available, don't spin new threads
|
|
71
|
+
if self._idle_semaphore.acquire(timeout=0):
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# When the executor gets lost, the weakref callback will wake up
|
|
75
|
+
# the worker threads.
|
|
76
|
+
def weakref_cb(_, q=self._work_queue):
|
|
77
|
+
q.put(None)
|
|
78
|
+
|
|
79
|
+
num_threads = len(self._threads)
|
|
80
|
+
if num_threads < self._max_workers:
|
|
81
|
+
thread_name = "%s_%d" % (self._thread_name_prefix or self, num_threads)
|
|
82
|
+
t = threading.Thread(
|
|
83
|
+
name=thread_name, target=_worker_with_cleanup, args=(weakref.ref(self, weakref_cb), self._work_queue, self._initializer, self._initargs)
|
|
84
|
+
)
|
|
85
|
+
t.start()
|
|
86
|
+
self._threads.add(t) # type: ignore
|
|
87
|
+
concurrent.futures.thread._threads_queues[t] = self._work_queue # type: ignore
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def ssg_crawl(base_url: str):
|
|
91
|
+
license.check("SSG")
|
|
92
|
+
import solara.server.app
|
|
93
|
+
import solara.server.kernel
|
|
94
|
+
|
|
95
|
+
build_path = settings.ssg.build_path
|
|
96
|
+
assert build_path is not None
|
|
97
|
+
build_path.mkdir(exist_ok=True)
|
|
98
|
+
|
|
99
|
+
app_script = solara.server.app.apps["__default__"]
|
|
100
|
+
rprint(f"Building {app_script.name} at {build_path}")
|
|
101
|
+
routes = app_script.routes
|
|
102
|
+
|
|
103
|
+
# although in theory we should be able to run this with multiple threads
|
|
104
|
+
# there are issues with uvloop:
|
|
105
|
+
# e.g.: "Racing with another loop to spawn a process."
|
|
106
|
+
thread_pool = CleanupThreadPoolExecutor(max_workers=1)
|
|
107
|
+
|
|
108
|
+
results = []
|
|
109
|
+
for route in routes:
|
|
110
|
+
results.append(thread_pool.submit(ssg_crawl_route, f"{base_url}/", route, build_path, thread_pool))
|
|
111
|
+
|
|
112
|
+
def wait(async_result):
|
|
113
|
+
results = async_result.result()
|
|
114
|
+
for result in results:
|
|
115
|
+
wait(result)
|
|
116
|
+
|
|
117
|
+
for result in results:
|
|
118
|
+
wait(result)
|
|
119
|
+
|
|
120
|
+
thread_pool.shutdown()
|
|
121
|
+
|
|
122
|
+
rprint("Done building SSG")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def ssg_crawl_route(base_url: str, route: solara.Route, build_path: Path, thread_pool: CleanupThreadPoolExecutor):
|
|
126
|
+
# if route
|
|
127
|
+
url = base_url + (route.path if route.path != "/" else "")
|
|
128
|
+
if not route.children:
|
|
129
|
+
rprint("Check SSG for URL", url)
|
|
130
|
+
build_path.mkdir(exist_ok=True, parents=True)
|
|
131
|
+
path = build_path / ("index.html" if route.path == "/" else route.path + ".html")
|
|
132
|
+
stale = False
|
|
133
|
+
pw = _get_playwright()
|
|
134
|
+
page = pw.page
|
|
135
|
+
if path.exists():
|
|
136
|
+
if route.file is None:
|
|
137
|
+
rprint(f"File corresponding to {url} is not found (route: {route})")
|
|
138
|
+
else:
|
|
139
|
+
assert route.file is not None
|
|
140
|
+
stale = path.stat().st_mtime < route.file.stat().st_mtime
|
|
141
|
+
if stale:
|
|
142
|
+
rprint(f"Path {path} is stale: mtime {path} is older than {route.file} mtime {route.file.stat().st_mtime}")
|
|
143
|
+
if not path.exists() or stale:
|
|
144
|
+
rprint(f"Will generate {path}")
|
|
145
|
+
response = page.goto(url, wait_until="networkidle")
|
|
146
|
+
if response.status != 200:
|
|
147
|
+
raise Exception(f"Failed to load {url} with status {response.status}")
|
|
148
|
+
# TODO: if we don't want to detached, we get stack trace showing errors in solara
|
|
149
|
+
# make sure the html is loaded
|
|
150
|
+
try:
|
|
151
|
+
page.locator("#app").wait_for()
|
|
152
|
+
# make sure vue took over
|
|
153
|
+
page.locator("#pre-rendered-html-present").wait_for(state="detached")
|
|
154
|
+
# and wait for the
|
|
155
|
+
page.locator("text=Loading app").wait_for(state="detached")
|
|
156
|
+
page.locator("#kernel-busy-indicator").wait_for(state="hidden")
|
|
157
|
+
# page.wait_
|
|
158
|
+
time.sleep(0.5)
|
|
159
|
+
raw_html = page.content()
|
|
160
|
+
except Exception:
|
|
161
|
+
logger.exception("Failure retrieving content for url: %s", url)
|
|
162
|
+
raise
|
|
163
|
+
request_path = urllib.parse.urlparse(url).path
|
|
164
|
+
|
|
165
|
+
import solara.server.server
|
|
166
|
+
|
|
167
|
+
# the html from playwright is not what we want, pass it through the jinja template again
|
|
168
|
+
html = solara.server.server.read_root(request_path, ssg_data=_ssg_data(raw_html))
|
|
169
|
+
if html is None:
|
|
170
|
+
raise Exception(f"Failed to render {url}")
|
|
171
|
+
path.write_text(html, encoding="utf-8")
|
|
172
|
+
rprint(f"Wrote to {path}")
|
|
173
|
+
page.goto("about:blank")
|
|
174
|
+
else:
|
|
175
|
+
rprint(f"Skipping existing render: {path}")
|
|
176
|
+
results = []
|
|
177
|
+
for child in route.children:
|
|
178
|
+
result = thread_pool.submit(ssg_crawl_route, url + "/", child, build_path / Path(route.path), thread_pool)
|
|
179
|
+
results.append(result)
|
|
180
|
+
return results
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def ssg_content(path: str) -> Optional[str]:
|
|
184
|
+
license.check("SSG")
|
|
185
|
+
# still not sure why we sometimes end with a double slash
|
|
186
|
+
if path.endswith("//"):
|
|
187
|
+
path = path[:-2]
|
|
188
|
+
if path.endswith("/"):
|
|
189
|
+
path = path[:-1]
|
|
190
|
+
if path.startswith("/"):
|
|
191
|
+
# remove / so we don't get absolute paths on disk
|
|
192
|
+
path = path[1:]
|
|
193
|
+
# TODO: how do we know the app?
|
|
194
|
+
build_path = settings.ssg.build_path
|
|
195
|
+
if build_path and settings.ssg.enabled:
|
|
196
|
+
html_path = build_path / path
|
|
197
|
+
if (html_path / "index.html").exists():
|
|
198
|
+
html_path = html_path / "index.html"
|
|
199
|
+
else:
|
|
200
|
+
html_path = html_path.with_suffix(".html")
|
|
201
|
+
if html_path.exists() and html_path.is_file():
|
|
202
|
+
logger.info("Using pre-rendered html at %r", html_path)
|
|
203
|
+
return html_path.read_text("utf8")
|
|
204
|
+
else:
|
|
205
|
+
logger.error("Count not find html at %r", html_path)
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _ssg_data(html: str) -> Optional[SSGData]:
|
|
210
|
+
license.check("SSG")
|
|
211
|
+
from bs4 import BeautifulSoup, Tag
|
|
212
|
+
|
|
213
|
+
# pre_rendered_css = ""
|
|
214
|
+
styles = []
|
|
215
|
+
title = "Solara ☀️"
|
|
216
|
+
|
|
217
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
218
|
+
node = soup.find(id="app")
|
|
219
|
+
# TODO: add classes...
|
|
220
|
+
if node and isinstance(node, Tag):
|
|
221
|
+
# only render children
|
|
222
|
+
html = "".join(str(x) for x in node.contents)
|
|
223
|
+
title_tag = soup.find("title")
|
|
224
|
+
if title_tag:
|
|
225
|
+
title = title_tag.text
|
|
226
|
+
|
|
227
|
+
# include all meta tags
|
|
228
|
+
rendered_metas = soup.find_all("meta")
|
|
229
|
+
metas = []
|
|
230
|
+
for meta in rendered_metas:
|
|
231
|
+
# but only the ones added by solara
|
|
232
|
+
if meta.attrs.get("data-solara-head-key"):
|
|
233
|
+
metas.append(str(meta))
|
|
234
|
+
|
|
235
|
+
# include all styles
|
|
236
|
+
rendered_styles = soup.find_all("style")
|
|
237
|
+
for style in rendered_styles:
|
|
238
|
+
style_html = str(style)
|
|
239
|
+
# skip css that was already in the template so we don't include it multiple times
|
|
240
|
+
# or such that we do not include the CSS from the theme as ssg build time
|
|
241
|
+
if 'class="solara-template-css"' in style_html:
|
|
242
|
+
continue
|
|
243
|
+
# in case we want to skip the mathjax css
|
|
244
|
+
# if "MJXZERO" in style_html:
|
|
245
|
+
# continue
|
|
246
|
+
# pre_rendered_css += style_html
|
|
247
|
+
styles.append(style_html)
|
|
248
|
+
logger.debug("Include style (size is %r mb):\n\t%r", len(style_html) / 1024**2, style_html[:200])
|
|
249
|
+
return SSGData(title=title, html=html, styles=styles, metas=metas)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: solara-enterprise
|
|
3
|
+
Version: 1.45.0
|
|
4
|
+
Dynamic: Summary
|
|
5
|
+
Project-URL: Home, https://www.github.com/widgetti/solara
|
|
6
|
+
Author-email: "Maarten A. Breddels" <maartenbreddels@gmail.com>, Mario Buikhuizen <mariobuikhuizen@gmail.com>
|
|
7
|
+
License: Not open source, contact contact@solara.dev for licencing.
|
|
8
|
+
Classifier: License :: Free for non-commercial use
|
|
9
|
+
Requires-Python: >=3.7
|
|
10
|
+
Requires-Dist: solara-server==1.45.0
|
|
11
|
+
Requires-Dist: solara-ui==1.45.0
|
|
12
|
+
Provides-Extra: all
|
|
13
|
+
Requires-Dist: authlib; extra == 'all'
|
|
14
|
+
Requires-Dist: beautifulsoup4; extra == 'all'
|
|
15
|
+
Requires-Dist: diskcache; extra == 'all'
|
|
16
|
+
Requires-Dist: httpx; extra == 'all'
|
|
17
|
+
Requires-Dist: itsdangerous; extra == 'all'
|
|
18
|
+
Requires-Dist: playwright; extra == 'all'
|
|
19
|
+
Provides-Extra: auth
|
|
20
|
+
Requires-Dist: authlib; extra == 'auth'
|
|
21
|
+
Requires-Dist: httpx; extra == 'auth'
|
|
22
|
+
Requires-Dist: itsdangerous; extra == 'auth'
|
|
23
|
+
Provides-Extra: cache
|
|
24
|
+
Requires-Dist: diskcache; extra == 'cache'
|
|
25
|
+
Provides-Extra: ssg
|
|
26
|
+
Requires-Dist: beautifulsoup4; extra == 'ssg'
|
|
27
|
+
Requires-Dist: playwright; extra == 'ssg'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
solara_enterprise/__init__.py,sha256=92t-t5BTHmHGobETurAJRgZyY2DaxpO8axxCnR0EhjI,57
|
|
2
|
+
solara_enterprise/license.py,sha256=GCGEs3x9rtKf0dYUcHkx-yteIQuRlgnS8hPbl9BDAXs,412
|
|
3
|
+
solara_enterprise/ssg.py,sha256=uw3397RX0Qmg-OqOsgwaePDymhUvZTTFcBKkSWy1uCk,8935
|
|
4
|
+
solara_enterprise/auth/__init__.py,sha256=83RGLIkVJrb6R1iZxg5YS0R3kaCmqMHu4qs8S74LJXo,513
|
|
5
|
+
solara_enterprise/auth/components.py,sha256=mTazvLWJrduS6CrcsY60BJgkvQztH5X67CkOCRFXYV4,4038
|
|
6
|
+
solara_enterprise/auth/flask.py,sha256=Y__abnQPnP1cdQAkU8BBg9FuWMZ1rNnUmTLE_x19MlE,3539
|
|
7
|
+
solara_enterprise/auth/middleware.py,sha256=f9yshp6e0M2RSBaJaoeZisYlnM52NEYHTmsnjrW-h3c,5027
|
|
8
|
+
solara_enterprise/auth/starlette.py,sha256=-vow0-dhOLn_H5QJ79F4jynZnn6O63iHVYWqgtxMz4Q,3937
|
|
9
|
+
solara_enterprise/auth/utils.py,sha256=E54A4NNaRWhPLxB7K-B3YlHZsaCySyf3aT0Db0khOLo,1506
|
|
10
|
+
solara_enterprise/cache/__init__.py,sha256=pvQocwiJeUF6V8KFtQl0xPNGo7kNnWhjWCOcChwMP_Q,47
|
|
11
|
+
solara_enterprise/cache/base.py,sha256=Gx-p7khDFSxbdgG83aka7zABx5dLRtt9d9j4Zv69lwU,2098
|
|
12
|
+
solara_enterprise/cache/disk.py,sha256=isHvt0v7KlZ-MHYlSfBclWkreL30OjmZ7ZBMWmCqGl4,1190
|
|
13
|
+
solara_enterprise/cache/memory_size.py,sha256=5PAUaxVvHMIFINA3Z4z48zDvAATTFkEd0ehTXT5ahIA,816
|
|
14
|
+
solara_enterprise/cache/multi_level.py,sha256=hMjruI1cwNwDajaRXAJ7TxQqfw5XWRvtYuAU7M4f_W8,710
|
|
15
|
+
solara_enterprise/cache/redis.py,sha256=_GlACNJ5hfpRJ2Hw_2k3ma6gwEVLoVNTplS0Q5cGcRk,779
|
|
16
|
+
solara_enterprise/search/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
solara_enterprise/search/index.py,sha256=W98DC_6SmQDCfX5YcmS6OfZbO0LheleBmo0FUI7NOqU,3303
|
|
18
|
+
solara_enterprise/search/search.py,sha256=MzChv6DAsG2_ehPltI87orQqBqtY1xkBzXW8eDtMusU,707
|
|
19
|
+
solara_enterprise/search/search.vue,sha256=PSE8KlKHQQY-ZbaNj4eOGSIB9AXH8qmdTDqnDb0l5Kc,6005
|
|
20
|
+
solara_enterprise-1.45.0.dist-info/METADATA,sha256=T8zMnIAR9ZtJ1bEaEuZPAHiudb2gPUtMsR3KTNI6ZSI,1045
|
|
21
|
+
solara_enterprise-1.45.0.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
|
|
22
|
+
solara_enterprise-1.45.0.dist-info/licenses/LICENSE,sha256=04_xbTWtvdcQomu6IXsIkniKk8EcD9P1GyJM2dYPoSU,59
|
|
23
|
+
solara_enterprise-1.45.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Not open source, contact contact@solara.dev for licencing.
|