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

1from __future__ import annotations 

2 

3import dataclasses as dc 

4import hashlib 

5import os 

6import sys 

7from enum import Enum 

8from pathlib import Path 

9 

10from werkzeug.utils import cached_property 

11 

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 

15 

16 

17@dc.dataclass 

18class Project: 

19 name: str 

20 config: Path 

21 root: Path 

22 themes: list[str] = dc.field(default_factory=list) 

23 

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() 

28 

29 @property 

30 def tree(self): 

31 return str(self.root) 

32 

33 @property 

34 def project_file(self): 

35 return str(self.config) 

36 

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 

43 

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 

71 

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) 

76 

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 

83 

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 ) 

89 

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 = [] 

95 

96 return cls( 

97 name=name, 

98 config=Path(filename), 

99 root=Path(path), 

100 themes=themes, 

101 ) 

102 

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)) 

110 

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 

114 

115 @property 

116 def project_path(self): 

117 return self.project_file or self.tree 

118 

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) 

128 

129 class PackageCacheType(Enum): 

130 VENV = "venv" # The new virtual environment-based package cache 

131 FLAT = "flat" # No longer used flat-directory package cache 

132 

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" 

139 

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")) 

144 

145 return Path(get_cache_dir(), cache_name, h.hexdigest()) 

146 

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 

158 

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 

165 

166 def make_env(self, load_plugins=True): 

167 """Create a new environment for this project.""" 

168 return Environment(self, load_plugins=load_plugins) 

169 

170 @cached_property 

171 def excluded_assets(self): 

172 """List of glob patterns matching filenames of excluded assets. 

173 

174 Combines with default EXCLUDED_ASSETS. 

175 """ 

176 config = self.open_config() 

177 return list(comma_delimited(config.get("project.excluded_assets", ""))) 

178 

179 @cached_property 

180 def included_assets(self): 

181 """List of glob patterns matching filenames of included assets. 

182 

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", ""))) 

187 

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 }