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
185
186
187
188
189
190
191
192
193
194
195
196
197
from __future__ import annotations

import dataclasses as dc
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


@dc.dataclass
class Project:
    name: str
    config: Path
    root: Path
    themes: list[str] = dc.field(default_factory=list)

    def __post_init__(self):
        self.id = hashlib.md5(str(self.tree).encode("utf-8")).hexdigest()
        self.root = self.root.resolve()
        self.config = self.config.resolve()

    @property
    def tree(self):
        return str(self.root)

    @property
    def project_file(self):
        return str(self.config)

    @classmethod
    def discover(cls, base: Path | None = None) -> None | Project:
        """Auto discovers the closest project."""
        top = Path.cwd().resolve()
        here = (base.relative_to(top) if base else top).resolve()
        import inspect

        caller_frame = inspect.stack()[1]
        name = caller_frame.function
        lineno = caller_frame.lineno
        skip = {
            ("test_project_discovery", 41),
        }
        cond = (
            sys.platform == "win32"
            and (name, lineno) not in skip
            and ("~" in str(top) or "~" in str(here) or "~" in str(base))
        )
        if cond:
            raise RuntimeError(f"""
==xyz===> {name}:{lineno}
==xyz===> {base=}
==xyz===> {top=}
==xyz===> {here=}
""")
            print(f"==xyz===> {top=}", file=sys.stderr)
            print(f"==xyz===> {here=}", file=sys.stderr)
        while True:
            if project := cls.from_path(here, extension_required=True):
                return project
            if here == top:
                break
            here = here.parent
        return None

    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: str | Path) -> Project | None:
        """Reads a project from a project file."""
        inifile = IniFile(str(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,
            config=Path(filename),
            root=Path(path),
            themes=themes,
        )

    @classmethod
    def from_path(cls, path: Path, extension_required=False) -> Project | None:
        path = Path(path)
        if not path.is_dir():
            if extension_required and path.suffix != ".lektorproject":
                return None
            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])) if paths else 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,
        }