lektor_ng.project

src/lektor_ng/project.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
from __future__ import annotations

import hashlib
import os
import sys
from enum import Enum
from pathlib import Path

from werkzeug.utils import cached_property

from lektor_ng.environment import Environment
from lektor_ng.inifile import IniFile
from lektor_ng.utils import comma_delimited, get_cache_dir, untrusted_to_os_path


class Project:
    def __init__(self, name, project_file, tree, themes=None):
        self.name = name
        self.project_file = project_file
        self.tree = os.path.normpath(tree)
        self.themes = themes or []
        self.id = hashlib.md5(self.tree.encode("utf-8")).hexdigest()

    def open_config(self):
        if self.project_file is None:
            raise RuntimeError("This project has no project file.")
        return IniFile(self.project_file)

    @classmethod
    def from_file(cls, filename):
        """Reads a project from a project file."""
        inifile = IniFile(filename)
        if inifile.is_new:
            return None

        name = inifile.get("project.name") or os.path.basename(filename).rsplit(".")[0].title()
        path = os.path.join(
            os.path.dirname(filename),
            untrusted_to_os_path(inifile.get("project.path") or "."),
        )

        themes = inifile.get("project.themes")
        if themes is not None:
            themes = [x.strip() for x in themes.split(",")]
        else:
            themes = []

        return cls(
            name=name,
            project_file=filename,
            tree=path,
            themes=themes,
        )

    @classmethod
    def from_path2(cls, path: Path) -> list[Project] | None:
        if not path.is_dir():
            return cls.from_file(str(path))
        if len(paths := list(path.glob("*.lektorproject"))) > 1:
            raise RuntimeError(f"multiple project files: {paths}")
        return cls.from_file(str(paths[0]))

    @classmethod
    def from_path(cls, path, extension_required=False):
        """Locates the project for a path."""
        path = os.path.abspath(path)
        if os.path.isfile(path) and (not extension_required or path.endswith(".lektorproject")):
            return cls.from_file(path)

        try:
            files = [x for x in os.listdir(path) if x.lower().endswith(".lektorproject")]
        except OSError:
            return None

        if len(files) == 1:
            return cls.from_file(os.path.join(path, files[0]))

        if os.path.isdir(path) and os.path.isfile(os.path.join(path, "content/contents.lr")):
            return cls(
                name=os.path.basename(path),
                project_file=None,
                tree=path,
            )
        return None

    @classmethod
    def discover(cls, base=None):
        """Auto discovers the closest project."""
        if base is None:
            base = os.getcwd()
        here = base
        while 1:
            project = cls.from_path(here, extension_required=True)
            if project is not None:
                return project
            node = os.path.dirname(here)
            if node == here:
                break
            here = node
        return None

    @property
    def project_path(self):
        return self.project_file or self.tree

    def get_output_path(self):
        """The path where output files are stored."""
        config = self.open_config()  # raises if no project_file
        output_path = config.get("project.output_path")
        if output_path:
            path = Path(config.filename).parent / output_path
        else:
            path = Path(get_cache_dir(), "builds", self.id)
        return str(path)

    class PackageCacheType(Enum):
        VENV = "venv"  # The new virtual environment-based package cache
        FLAT = "flat"  # No longer used flat-directory package cache

    def get_package_cache_path(self, cache_type: PackageCacheType = PackageCacheType.VENV) -> Path:
        """The path where plugin packages are stored."""
        if cache_type is self.PackageCacheType.FLAT:
            cache_name = "packages"
        else:
            cache_name = "venvs"

        h = hashlib.md5()
        h.update(self.id.encode("utf-8"))
        h.update(sys.version.encode("utf-8"))
        h.update(sys.prefix.encode("utf-8"))

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

    def content_path_from_filename(self, filename):
        """Given a filename returns the content path or None if
        not in project.
        """
        dirname, basename = os.path.split(os.path.abspath(filename))
        if basename == "contents.lr":
            path = dirname
        elif basename.endswith(".lr"):
            path = os.path.join(dirname, basename[:-3])
        else:
            return None

        content_path = os.path.normpath(self.tree).split(os.path.sep) + ["content"]
        file_path = os.path.normpath(path).split(os.path.sep)
        prefix = os.path.commonprefix([content_path, file_path])
        if prefix == content_path:
            return "/" + "/".join(file_path[len(content_path) :])
        return None

    def make_env(self, load_plugins=True):
        """Create a new environment for this project."""
        return Environment(self, load_plugins=load_plugins)

    @cached_property
    def excluded_assets(self):
        """List of glob patterns matching filenames of excluded assets.

        Combines with default EXCLUDED_ASSETS.
        """
        config = self.open_config()
        return list(comma_delimited(config.get("project.excluded_assets", "")))

    @cached_property
    def included_assets(self):
        """List of glob patterns matching filenames of included assets.

        Overrides both excluded_assets and the default excluded patterns.
        """
        config = self.open_config()
        return list(comma_delimited(config.get("project.included_assets", "")))

    def to_json(self):
        return {
            "name": self.name,
            "project_file": self.project_file,
            "project_path": self.project_path,
            "default_output_path": self.get_output_path(),
            "package_cache_path": str(self.get_package_cache_path()),
            "id": self.id,
            "tree": self.tree,
        }