Coverage for src/lektor_ng/project.py: 83%
108 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-08 10:40 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-08 10:40 +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_path(cls, path, extension_required=False):
57 """Locates the project for a path."""
58 path = os.path.abspath(path)
59 if os.path.isfile(path) and (not extension_required or path.endswith(".lektorproject")):
60 return cls.from_file(path)
62 try:
63 files = [x for x in os.listdir(path) if x.lower().endswith(".lektorproject")]
64 except OSError:
65 return None
67 if len(files) == 1:
68 return cls.from_file(os.path.join(path, files[0]))
70 if os.path.isdir(path) and os.path.isfile(os.path.join(path, "content/contents.lr")):
71 return cls(
72 name=os.path.basename(path),
73 project_file=None,
74 tree=path,
75 )
76 return None
78 @classmethod
79 def discover(cls, base=None):
80 """Auto discovers the closest project."""
81 if base is None:
82 base = os.getcwd()
83 here = base
84 while 1:
85 project = cls.from_path(here, extension_required=True)
86 if project is not None:
87 return project
88 node = os.path.dirname(here)
89 if node == here:
90 break
91 here = node
92 return None
94 @property
95 def project_path(self):
96 return self.project_file or self.tree
98 def get_output_path(self):
99 """The path where output files are stored."""
100 config = self.open_config() # raises if no project_file
101 output_path = config.get("project.output_path")
102 if output_path:
103 path = Path(config.filename).parent / output_path
104 else:
105 path = Path(get_cache_dir(), "builds", self.id)
106 return str(path)
108 class PackageCacheType(Enum):
109 VENV = "venv" # The new virtual environment-based package cache
110 FLAT = "flat" # No longer used flat-directory package cache
112 def get_package_cache_path(self, cache_type: PackageCacheType = PackageCacheType.VENV) -> Path:
113 """The path where plugin packages are stored."""
114 if cache_type is self.PackageCacheType.FLAT:
115 cache_name = "packages"
116 else:
117 cache_name = "venvs"
119 h = hashlib.md5()
120 h.update(self.id.encode("utf-8"))
121 h.update(sys.version.encode("utf-8"))
122 h.update(sys.prefix.encode("utf-8"))
124 return Path(get_cache_dir(), cache_name, h.hexdigest())
126 def content_path_from_filename(self, filename):
127 """Given a filename returns the content path or None if
128 not in project.
129 """
130 dirname, basename = os.path.split(os.path.abspath(filename))
131 if basename == "contents.lr":
132 path = dirname
133 elif basename.endswith(".lr"):
134 path = os.path.join(dirname, basename[:-3])
135 else:
136 return None
138 content_path = os.path.normpath(self.tree).split(os.path.sep) + ["content"]
139 file_path = os.path.normpath(path).split(os.path.sep)
140 prefix = os.path.commonprefix([content_path, file_path])
141 if prefix == content_path:
142 return "/" + "/".join(file_path[len(content_path) :])
143 return None
145 def make_env(self, load_plugins=True):
146 """Create a new environment for this project."""
147 return Environment(self, load_plugins=load_plugins)
149 @cached_property
150 def excluded_assets(self):
151 """List of glob patterns matching filenames of excluded assets.
153 Combines with default EXCLUDED_ASSETS.
154 """
155 config = self.open_config()
156 return list(comma_delimited(config.get("project.excluded_assets", "")))
158 @cached_property
159 def included_assets(self):
160 """List of glob patterns matching filenames of included assets.
162 Overrides both excluded_assets and the default excluded patterns.
163 """
164 config = self.open_config()
165 return list(comma_delimited(config.get("project.included_assets", "")))
167 def to_json(self):
168 return {
169 "name": self.name,
170 "project_file": self.project_file,
171 "project_path": self.project_path,
172 "default_output_path": self.get_output_path(),
173 "package_cache_path": str(self.get_package_cache_path()),
174 "id": self.id,
175 "tree": self.tree,
176 }