Coverage for src/lektor_ng/project.py: 83%

109 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-05 16:51 +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 

27 @property 

28 def tree(self): 

29 return str(self.root) 

30 

31 @property 

32 def project_file(self): 

33 return str(self.config) 

34 

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 

47 

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) 

52 

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 

59 

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 ) 

65 

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

71 

72 return cls( 

73 name=name, 

74 config=Path(filename), 

75 root=Path(path), 

76 themes=themes, 

77 ) 

78 

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

86 

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 

90 

91 @property 

92 def project_path(self): 

93 return self.project_file or self.tree 

94 

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) 

104 

105 class PackageCacheType(Enum): 

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

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

108 

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" 

115 

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

120 

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

122 

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 

134 

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 

141 

142 def make_env(self, load_plugins=True): 

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

144 return Environment(self, load_plugins=load_plugins) 

145 

146 @cached_property 

147 def excluded_assets(self): 

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

149 

150 Combines with default EXCLUDED_ASSETS. 

151 """ 

152 config = self.open_config() 

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

154 

155 @cached_property 

156 def included_assets(self): 

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

158 

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

163 

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 }