Coverage for src/lektor_ng/environment/config.py: 93%
151 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-08 23:29 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-08 23:29 +0000
1import copy
2import os
3import re
4from collections import OrderedDict
5from urllib.parse import urlsplit
7from werkzeug.utils import cached_property
9from lektor_ng.constants import PRIMARY_ALT
10from lektor_ng.i18n import get_i18n_block
11from lektor_ng.inifile import IniFile
12from lektor_ng.utils import bool_from_string, secure_url
14DEFAULT_CONFIG = {
15 "EPHEMERAL_RECORD_CACHE_SIZE": 500,
16 "ATTACHMENT_TYPES": {
17 # Only enable image formats here that we can handle in imagetools.
18 # Right now this is limited to jpg, png and gif.
19 ".jpg": "image",
20 ".jpeg": "image",
21 ".png": "image",
22 ".gif": "image",
23 ".svg": "image",
24 ".avi": "video",
25 ".mpg": "video",
26 ".mpeg": "video",
27 ".wmv": "video",
28 ".ogv": "video",
29 ".mp4": "video",
30 ".mp3": "audio",
31 ".wav": "audio",
32 ".ogg": "audio",
33 ".pdf": "document",
34 ".doc": "document",
35 ".docx": "document",
36 ".htm": "document",
37 ".html": "document",
38 ".txt": "text",
39 ".log": "text",
40 },
41 "PROJECT": {
42 "name": None,
43 "locale": "en_US",
44 "url": None,
45 "url_style": "relative",
46 },
47 "THEME_SETTINGS": {},
48 "PACKAGES": {},
49 "ALTERNATIVES": OrderedDict(),
50 "PRIMARY_ALTERNATIVE": None,
51 "SERVERS": {},
52}
55def update_config_from_ini(config, inifile):
56 for section_name in ("ATTACHMENT_TYPES", "PROJECT", "PACKAGES", "THEME_SETTINGS"):
57 section_config = inifile.section_as_dict(section_name.lower())
58 config[section_name].update(section_config)
60 for sect in inifile.sections():
61 if sect.startswith("servers."):
62 server_id = sect.split(".")[1]
63 config["SERVERS"][server_id] = inifile.section_as_dict(sect)
64 elif sect.startswith("alternatives."):
65 alt = sect.split(".")[1]
66 config["ALTERNATIVES"][alt] = {
67 "name": get_i18n_block(inifile, f"alternatives.{alt}.name"),
68 "url_prefix": inifile.get(f"alternatives.{alt}.url_prefix"),
69 "url_suffix": inifile.get(f"alternatives.{alt}.url_suffix"),
70 "primary": inifile.get_bool(f"alternatives.{alt}.primary"),
71 "locale": inifile.get(f"alternatives.{alt}.locale", "en_US"),
72 }
74 for alt, alt_data in config["ALTERNATIVES"].items():
75 if alt_data["primary"]:
76 config["PRIMARY_ALTERNATIVE"] = alt
77 break
78 else:
79 if config["ALTERNATIVES"]:
80 raise RuntimeError("Alternatives defined but no primary set.")
83class ServerInfo:
84 def __init__(self, id, name_i18n, target, enabled=True, default=False, extra=None):
85 self.id = id
86 self.name_i18n = name_i18n
87 self.target = target
88 self.enabled = enabled
89 self.default = default
90 self.extra = extra or {}
92 @property
93 def name(self):
94 return self.name_i18n.get("en") or self.id
96 @property
97 def short_target(self):
98 match = re.match(r"([a-z]+)://([^/]+)", self.target)
99 if match is not None:
100 protocol, server = match.groups()
101 return f"{server} via {protocol}"
102 return self.target
104 def to_json(self):
105 return {
106 "id": self.id,
107 "name": self.name,
108 "name_i18n": self.name_i18n,
109 "target": self.target,
110 "short_target": self.short_target,
111 "enabled": self.enabled,
112 "default": self.default,
113 "extra": self.extra,
114 }
117class Config:
118 def __init__(self, filename=None):
119 self.filename = filename
120 self.values = copy.deepcopy(DEFAULT_CONFIG)
122 if filename is not None and os.path.isfile(filename):
123 inifile = IniFile(filename)
124 update_config_from_ini(self.values, inifile)
126 def __getitem__(self, name):
127 return self.values[name]
129 @property
130 def site_locale(self):
131 """The locale of this project."""
132 return self.values["PROJECT"]["locale"]
134 def get_servers(self, public=False):
135 """Returns a list of servers."""
136 rv = {}
137 for server in self.values["SERVERS"]:
138 server_info = self.get_server(server, public=public)
139 if server_info is None:
140 continue
141 rv[server] = server_info
142 return rv
144 def get_default_server(self, public=False):
145 """Returns the default server."""
146 choices = []
147 for server in self.values["SERVERS"]:
148 server_info = self.get_server(server, public=public)
149 if server_info is not None:
150 if server_info.default:
151 return server_info
152 choices.append(server_info)
153 if len(choices) == 1:
154 return choices[0]
155 return None
157 def get_server(self, name, public=False):
158 """Looks up a server info by name."""
159 info = self.values["SERVERS"].get(name)
160 if info is None or "target" not in info:
161 return None
162 info = info.copy()
163 target = info.pop("target")
164 if public:
165 target = secure_url(target)
166 return ServerInfo(
167 id=name,
168 name_i18n=get_i18n_block(info, "name", pop=True),
169 target=target,
170 enabled=bool_from_string(info.pop("enabled", None), True),
171 default=bool_from_string(info.pop("default", None), False),
172 extra=info,
173 )
175 def is_valid_alternative(self, alt):
176 """Checks if an alternative ID is known."""
177 if alt == PRIMARY_ALT:
178 return True
179 return alt in self.values["ALTERNATIVES"]
181 def list_alternatives(self):
182 """Returns a sorted list of alternative IDs."""
183 return sorted(self.values["ALTERNATIVES"])
185 def iter_alternatives(self):
186 """Iterates over all alternatives. If the system is disabled this
187 yields '_primary'.
188 """
189 found = False
190 for alt in self.values["ALTERNATIVES"]:
191 if alt != PRIMARY_ALT:
192 yield alt
193 found = True
194 if not found:
195 yield PRIMARY_ALT
197 def get_alternative(self, alt):
198 """Returns the config setting of the given alt."""
199 if alt == PRIMARY_ALT:
200 alt = self.primary_alternative
201 return self.values["ALTERNATIVES"].get(alt)
203 def get_alternative_url_prefixes(self):
204 """Returns a list of alternative url prefixes by length."""
205 items = [(v["url_prefix"].lstrip("/"), k) for k, v in self.values["ALTERNATIVES"].items() if v["url_prefix"]]
206 items.sort(key=lambda x: -len(x[0]))
207 return items
209 def get_alternative_url_suffixes(self):
210 """Returns a list of alternative url suffixes by length."""
211 items = [(v["url_suffix"].rstrip("/"), k) for k, v in self.values["ALTERNATIVES"].items() if v["url_suffix"]]
212 items.sort(key=lambda x: -len(x[0]))
213 return items
215 def get_alternative_url_span(self, alt=PRIMARY_ALT):
216 """Returns the URL span (prefix, suffix) for an alt."""
217 if alt == PRIMARY_ALT:
218 alt = self.primary_alternative
219 cfg = self.values["ALTERNATIVES"].get(alt)
220 if cfg is not None:
221 return cfg["url_prefix"] or "", cfg["url_suffix"] or ""
222 return "", ""
224 @cached_property
225 def primary_alternative_is_rooted(self):
226 """`True` if the primary alternative is sitting at the root of
227 the URL handler.
228 """
229 primary = self.primary_alternative
230 if primary is None:
231 return True
233 cfg = self.values["ALTERNATIVES"].get(primary)
234 if not (cfg["url_prefix"] or "").lstrip("/") and not (cfg["url_suffix"] or "").rstrip("/"):
235 return True
237 return False
239 @property
240 def primary_alternative(self):
241 """The identifier that acts as primary alternative."""
242 return self.values["PRIMARY_ALTERNATIVE"]
244 @cached_property
245 def base_url(self):
246 """The external base URL."""
247 url = self.values["PROJECT"].get("url")
248 if url and urlsplit(url).scheme:
249 return url.rstrip("/") + "/"
250 return None
252 @cached_property
253 def base_path(self):
254 """The base path of the URL."""
255 url = self.values["PROJECT"].get("url")
256 if url:
257 return urlsplit(url).path.rstrip("/") + "/"
258 return "/"
260 @cached_property
261 def url_style(self):
262 """The intended URL style."""
263 style = self.values["PROJECT"].get("url_style")
264 if style in ("relative", "absolute", "external"):
265 return style
266 return "relative"