Coverage for src/lektor_ng/project.py: 86%
119 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-07 13:13 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-07 13:13 +0000
1from __future__ import annotations
3import dataclasses as dc
4import hashlib
5import os
6import sys
7from enum import Enum
8from pathlib import Path
10from werkzeug.utils import cached_property
12from lektor_ng.environment import Environment
13from lektor_ng.inifile import IniFile
14from lektor_ng.utils import comma_delimited, get_cache_dir, untrusted_to_os_path
17@dc.dataclass
18class Project:
19 name: str
20 config: Path
21 root: Path
22 themes: list[str] = dc.field(default_factory=list)
24 def __post_init__(self):
25 self.id = hashlib.md5(str(self.tree).encode("utf-8")).hexdigest()
26 self.root = self.root.resolve()
27 self.config = self.config.resolve()
29 @property
30 def tree(self):
31 return str(self.root)
33 @property
34 def project_file(self):
35 return str(self.config)
37 @classmethod
38 def discover(cls, base: Path | None = None) -> None | Project:
39 """Auto discovers the closest project."""
40 top = Path.cwd().resolve()
41 here = (base.relative_to(top) if base else top).resolve()
42 import inspect
44 caller_frame = inspect.stack()[1]
45 name = caller_frame.function
46 lineno = caller_frame.lineno
47 skip = {
48 ("test_project_discovery", 41),
49 }
50 cond = (
51 sys.platform == "win32"
52 and (name, lineno) not in skip
53 and ("~" in str(top) or "~" in str(here) or "~" in str(base))
54 )
55 if cond:
56 raise RuntimeError(f"""
57==xyz===> {name}:{lineno}
58==xyz===> {base=}
59==xyz===> {top=}
60==xyz===> {here=}
61""")
62 print(f"==xyz===> {top=}", file=sys.stderr)
63 print(f"==xyz===> {here=}", file=sys.stderr)
64 while True:
65 if project := cls.from_path(here, extension_required=True):
66 return project
67 if here == top:
68 break
69 here = here.parent
70 return None
72 def open_config(self):
73 if self.project_file is None:
74 raise RuntimeError("This project has no project file.")
75 return IniFile(self.project_file)
77 @classmethod
78 def from_file(cls, filename: str | Path) -> Project | None:
79 """Reads a project from a project file."""
80 inifile = IniFile(str(filename))
81 if inifile.is_new:
82 return None
84 name = inifile.get("project.name") or os.path.basename(filename).rsplit(".")[0].title()
85 path = os.path.join(
86 os.path.dirname(filename),
87 untrusted_to_os_path(inifile.get("project.path") or "."),
88 )
90 themes = inifile.get("project.themes")
91 if themes is not None:
92 themes = [x.strip() for x in themes.split(",")]
93 else:
94 themes = []
96 return cls(
97 name=name,
98 config=Path(filename),
99 root=Path(path),
100 themes=themes,
101 )
103 @classmethod
104 def from_path(cls, path: Path, extension_required=False) -> Project | None:
105 path = Path(path)
106 if not path.is_dir():
107 if extension_required and path.suffix != ".lektorproject":
108 return None
109 return cls.from_file(str(path))
111 if len(paths := list(path.glob("*.lektorproject"))) > 1:
112 raise RuntimeError(f"multiple project files: {paths}")
113 return cls.from_file(str(paths[0])) if paths else None
115 @property
116 def project_path(self):
117 return self.project_file or self.tree
119 def get_output_path(self):
120 """The path where output files are stored."""
121 config = self.open_config() # raises if no project_file
122 output_path = config.get("project.output_path")
123 if output_path:
124 path = Path(config.filename).parent / output_path
125 else:
126 path = Path(get_cache_dir(), "builds", self.id)
127 return str(path)
129 class PackageCacheType(Enum):
130 VENV = "venv" # The new virtual environment-based package cache
131 FLAT = "flat" # No longer used flat-directory package cache
133 def get_package_cache_path(self, cache_type: PackageCacheType = PackageCacheType.VENV) -> Path:
134 """The path where plugin packages are stored."""
135 if cache_type is self.PackageCacheType.FLAT:
136 cache_name = "packages"
137 else:
138 cache_name = "venvs"
140 h = hashlib.md5()
141 h.update(self.id.encode("utf-8"))
142 h.update(sys.version.encode("utf-8"))
143 h.update(sys.prefix.encode("utf-8"))
145 return Path(get_cache_dir(), cache_name, h.hexdigest())
147 def content_path_from_filename(self, filename):
148 """Given a filename returns the content path or None if
149 not in project.
150 """
151 dirname, basename = os.path.split(os.path.abspath(filename))
152 if basename == "contents.lr":
153 path = dirname
154 elif basename.endswith(".lr"):
155 path = os.path.join(dirname, basename[:-3])
156 else:
157 return None
159 content_path = os.path.normpath(self.tree).split(os.path.sep) + ["content"]
160 file_path = os.path.normpath(path).split(os.path.sep)
161 prefix = os.path.commonprefix([content_path, file_path])
162 if prefix == content_path:
163 return "/" + "/".join(file_path[len(content_path) :])
164 return None
166 def make_env(self, load_plugins=True):
167 """Create a new environment for this project."""
168 return Environment(self, load_plugins=load_plugins)
170 @cached_property
171 def excluded_assets(self):
172 """List of glob patterns matching filenames of excluded assets.
174 Combines with default EXCLUDED_ASSETS.
175 """
176 config = self.open_config()
177 return list(comma_delimited(config.get("project.excluded_assets", "")))
179 @cached_property
180 def included_assets(self):
181 """List of glob patterns matching filenames of included assets.
183 Overrides both excluded_assets and the default excluded patterns.
184 """
185 config = self.open_config()
186 return list(comma_delimited(config.get("project.included_assets", "")))
188 def to_json(self):
189 return {
190 "name": self.name,
191 "project_file": self.project_file,
192 "project_path": self.project_path,
193 "default_output_path": self.get_output_path(),
194 "package_cache_path": str(self.get_package_cache_path()),
195 "id": self.id,
196 "tree": self.tree,
197 }