Coverage for src/lektor_ng/project.py: 80%
115 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-31 01:21 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-31 01:21 +0000
1from __future__ import annotations
3import hashlib
4import os
5import sys
6from enum import Enum
7from pathlib import Path
9from werkzeug.utils import cached_property
11from lektor_ng.environment import Environment
12from lektor_ng.inifile import IniFile
13from lektor_ng.utils import comma_delimited, get_cache_dir, untrusted_to_os_path
16class Project:
17 def __init__(self, name, project_file, tree, themes=None):
18 self.name = name
19 self.project_file = project_file
20 self.tree = os.path.normpath(tree)
21 self.themes = themes or []
22 self.id = hashlib.md5(self.tree.encode("utf-8")).hexdigest()
24 def open_config(self):
25 if self.project_file is None:
26 raise RuntimeError("This project has no project file.")
27 return IniFile(self.project_file)
29 @classmethod
30 def from_file(cls, filename):
31 """Reads a project from a project file."""
32 inifile = IniFile(filename)
33 if inifile.is_new:
34 return None
36 name = inifile.get("project.name") or os.path.basename(filename).rsplit(".")[0].title()
37 path = os.path.join(
38 os.path.dirname(filename),
39 untrusted_to_os_path(inifile.get("project.path") or "."),
40 )
42 themes = inifile.get("project.themes")
43 if themes is not None:
44 themes = [x.strip() for x in themes.split(",")]
45 else:
46 themes = []
48 return cls(
49 name=name,
50 project_file=filename,
51 tree=path,
52 themes=themes,
53 )
55 @classmethod
56 def from_path2(cls, path: Path) -> list[Project] | None:
57 if not path.is_dir():
58 return cls.from_file(str(path))
59 if len(paths := list(path.glob("*.lektorproject"))) > 1:
60 raise RuntimeError(f"multiple project files: {paths}")
61 return cls.from_file(str(paths[0]))
63 @classmethod
64 def from_path(cls, path, extension_required=False):
65 """Locates the project for a path."""
66 path = os.path.abspath(path)
67 if os.path.isfile(path) and (not extension_required or path.endswith(".lektorproject")):
68 return cls.from_file(path)
70 try:
71 files = [x for x in os.listdir(path) if x.lower().endswith(".lektorproject")]
72 except OSError:
73 return None
75 if len(files) == 1:
76 return cls.from_file(os.path.join(path, files[0]))
78 if os.path.isdir(path) and os.path.isfile(os.path.join(path, "content/contents.lr")):
79 return cls(
80 name=os.path.basename(path),
81 project_file=None,
82 tree=path,
83 )
84 return None
86 @classmethod
87 def discover(cls, base=None):
88 """Auto discovers the closest project."""
89 if base is None:
90 base = os.getcwd()
91 here = base
92 while 1:
93 project = cls.from_path(here, extension_required=True)
94 if project is not None:
95 return project
96 node = os.path.dirname(here)
97 if node == here:
98 break
99 here = node
100 return None
102 @property
103 def project_path(self):
104 return self.project_file or self.tree
106 def get_output_path(self):
107 """The path where output files are stored."""
108 config = self.open_config() # raises if no project_file
109 output_path = config.get("project.output_path")
110 if output_path:
111 path = Path(config.filename).parent / output_path
112 else:
113 path = Path(get_cache_dir(), "builds", self.id)
114 return str(path)
116 class PackageCacheType(Enum):
117 VENV = "venv" # The new virtual environment-based package cache
118 FLAT = "flat" # No longer used flat-directory package cache
120 def get_package_cache_path(self, cache_type: PackageCacheType = PackageCacheType.VENV) -> Path:
121 """The path where plugin packages are stored."""
122 if cache_type is self.PackageCacheType.FLAT:
123 cache_name = "packages"
124 else:
125 cache_name = "venvs"
127 h = hashlib.md5()
128 h.update(self.id.encode("utf-8"))
129 h.update(sys.version.encode("utf-8"))
130 h.update(sys.prefix.encode("utf-8"))
132 return Path(get_cache_dir(), cache_name, h.hexdigest())
134 def content_path_from_filename(self, filename):
135 """Given a filename returns the content path or None if
136 not in project.
137 """
138 dirname, basename = os.path.split(os.path.abspath(filename))
139 if basename == "contents.lr":
140 path = dirname
141 elif basename.endswith(".lr"):
142 path = os.path.join(dirname, basename[:-3])
143 else:
144 return None
146 content_path = os.path.normpath(self.tree).split(os.path.sep) + ["content"]
147 file_path = os.path.normpath(path).split(os.path.sep)
148 prefix = os.path.commonprefix([content_path, file_path])
149 if prefix == content_path:
150 return "/" + "/".join(file_path[len(content_path) :])
151 return None
153 def make_env(self, load_plugins=True):
154 """Create a new environment for this project."""
155 return Environment(self, load_plugins=load_plugins)
157 @cached_property
158 def excluded_assets(self):
159 """List of glob patterns matching filenames of excluded assets.
161 Combines with default EXCLUDED_ASSETS.
162 """
163 config = self.open_config()
164 return list(comma_delimited(config.get("project.excluded_assets", "")))
166 @cached_property
167 def included_assets(self):
168 """List of glob patterns matching filenames of included assets.
170 Overrides both excluded_assets and the default excluded patterns.
171 """
172 config = self.open_config()
173 return list(comma_delimited(config.get("project.included_assets", "")))
175 def to_json(self):
176 return {
177 "name": self.name,
178 "project_file": self.project_file,
179 "project_path": self.project_path,
180 "default_output_path": self.get_output_path(),
181 "package_cache_path": str(self.get_package_cache_path()),
182 "id": self.id,
183 "tree": self.tree,
184 }