Coverage for src/lektor_ng/project.py: 82%
109 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 10:32 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 10:32 +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()
27 @property
28 def tree(self):
29 return str(self.root)
31 @property
32 def project_file(self):
33 return str(self.config)
35 @classmethod
36 def discover(cls, base: Path | None = None) -> None | Project:
37 """Auto discovers the closest project."""
38 top = Path.cwd()
39 here = (base.relative_to(top) if base else top).resolve()
40 while True:
41 if project := cls.from_path(here, extension_required=True):
42 return project
43 if here == top:
44 break
45 here = here.parent
46 return None
48 def open_config(self):
49 if self.project_file is None:
50 raise RuntimeError("This project has no project file.")
51 return IniFile(self.project_file)
53 @classmethod
54 def from_file(cls, filename: str | Path) -> Project | None:
55 """Reads a project from a project file."""
56 inifile = IniFile(str(filename))
57 if inifile.is_new:
58 return None
60 name = inifile.get("project.name") or os.path.basename(filename).rsplit(".")[0].title()
61 path = os.path.join(
62 os.path.dirname(filename),
63 untrusted_to_os_path(inifile.get("project.path") or "."),
64 )
66 themes = inifile.get("project.themes")
67 if themes is not None:
68 themes = [x.strip() for x in themes.split(",")]
69 else:
70 themes = []
72 return cls(
73 name=name,
74 config=Path(filename),
75 root=Path(path),
76 themes=themes,
77 )
79 @classmethod
80 def from_path(cls, path: Path, extension_required=False) -> Project | None:
81 path = Path(path)
82 if not path.is_dir():
83 if extension_required and path.suffix != ".lektorproject":
84 return None
85 return cls.from_file(str(path))
87 if len(paths := list(path.glob("*.lektorproject"))) > 1:
88 raise RuntimeError(f"multiple project files: {paths}")
89 return cls.from_file(str(paths[0])) if paths else None
91 @property
92 def project_path(self):
93 return self.project_file or self.tree
95 def get_output_path(self):
96 """The path where output files are stored."""
97 config = self.open_config() # raises if no project_file
98 output_path = config.get("project.output_path")
99 if output_path:
100 path = Path(config.filename).parent / output_path
101 else:
102 path = Path(get_cache_dir(), "builds", self.id)
103 return str(path)
105 class PackageCacheType(Enum):
106 VENV = "venv" # The new virtual environment-based package cache
107 FLAT = "flat" # No longer used flat-directory package cache
109 def get_package_cache_path(self, cache_type: PackageCacheType = PackageCacheType.VENV) -> Path:
110 """The path where plugin packages are stored."""
111 if cache_type is self.PackageCacheType.FLAT:
112 cache_name = "packages"
113 else:
114 cache_name = "venvs"
116 h = hashlib.md5()
117 h.update(self.id.encode("utf-8"))
118 h.update(sys.version.encode("utf-8"))
119 h.update(sys.prefix.encode("utf-8"))
121 return Path(get_cache_dir(), cache_name, h.hexdigest())
123 def content_path_from_filename(self, filename):
124 """Given a filename returns the content path or None if
125 not in project.
126 """
127 dirname, basename = os.path.split(os.path.abspath(filename))
128 if basename == "contents.lr":
129 path = dirname
130 elif basename.endswith(".lr"):
131 path = os.path.join(dirname, basename[:-3])
132 else:
133 return None
135 content_path = os.path.normpath(self.tree).split(os.path.sep) + ["content"]
136 file_path = os.path.normpath(path).split(os.path.sep)
137 prefix = os.path.commonprefix([content_path, file_path])
138 if prefix == content_path:
139 return "/" + "/".join(file_path[len(content_path) :])
140 return None
142 def make_env(self, load_plugins=True):
143 """Create a new environment for this project."""
144 return Environment(self, load_plugins=load_plugins)
146 @cached_property
147 def excluded_assets(self):
148 """List of glob patterns matching filenames of excluded assets.
150 Combines with default EXCLUDED_ASSETS.
151 """
152 config = self.open_config()
153 return list(comma_delimited(config.get("project.excluded_assets", "")))
155 @cached_property
156 def included_assets(self):
157 """List of glob patterns matching filenames of included assets.
159 Overrides both excluded_assets and the default excluded patterns.
160 """
161 config = self.open_config()
162 return list(comma_delimited(config.get("project.included_assets", "")))
164 def to_json(self):
165 return {
166 "name": self.name,
167 "project_file": self.project_file,
168 "project_path": self.project_path,
169 "default_output_path": self.get_output_path(),
170 "package_cache_path": str(self.get_package_cache_path()),
171 "id": self.id,
172 "tree": self.tree,
173 }