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

1from __future__ import annotations 

2 

3import hashlib 

4import os 

5import sys 

6from enum import Enum 

7from pathlib import Path 

8 

9from werkzeug.utils import cached_property 

10 

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 

14 

15 

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

23 

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) 

28 

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 

35 

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 ) 

41 

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

47 

48 return cls( 

49 name=name, 

50 project_file=filename, 

51 tree=path, 

52 themes=themes, 

53 ) 

54 

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

62 

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) 

69 

70 try: 

71 files = [x for x in os.listdir(path) if x.lower().endswith(".lektorproject")] 

72 except OSError: 

73 return None 

74 

75 if len(files) == 1: 

76 return cls.from_file(os.path.join(path, files[0])) 

77 

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 

85 

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 

101 

102 @property 

103 def project_path(self): 

104 return self.project_file or self.tree 

105 

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) 

115 

116 class PackageCacheType(Enum): 

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

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

119 

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" 

126 

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

131 

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

133 

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 

145 

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 

152 

153 def make_env(self, load_plugins=True): 

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

155 return Environment(self, load_plugins=load_plugins) 

156 

157 @cached_property 

158 def excluded_assets(self): 

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

160 

161 Combines with default EXCLUDED_ASSETS. 

162 """ 

163 config = self.open_config() 

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

165 

166 @cached_property 

167 def included_assets(self): 

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

169 

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

174 

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 }