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

108 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-03 17:58 +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_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) 

61 

62 try: 

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

64 except OSError: 

65 return None 

66 

67 if len(files) == 1: 

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

69 

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 

77 

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 

93 

94 @property 

95 def project_path(self): 

96 return self.project_file or self.tree 

97 

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) 

107 

108 class PackageCacheType(Enum): 

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

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

111 

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" 

118 

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

123 

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

125 

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 

137 

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 

144 

145 def make_env(self, load_plugins=True): 

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

147 return Environment(self, load_plugins=load_plugins) 

148 

149 @cached_property 

150 def excluded_assets(self): 

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

152 

153 Combines with default EXCLUDED_ASSETS. 

154 """ 

155 config = self.open_config() 

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

157 

158 @cached_property 

159 def included_assets(self): 

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

161 

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

166 

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 }