Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update/fix resolution of relative output path #1120

Merged
merged 3 commits into from
Apr 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
21 changes: 17 additions & 4 deletions lektor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from lektor.cli_utils import extraflag
from lektor.cli_utils import pass_context
from lektor.cli_utils import pruneflag
from lektor.cli_utils import ResolvedPath
from lektor.cli_utils import validate_language
from lektor.compat import importlib_metadata as metadata
from lektor.project import Project
Expand Down Expand Up @@ -48,7 +49,11 @@ def cli(ctx, project=None, language=None):

@cli.command("build")
@click.option(
"-O", "--output-path", type=click.Path(), default=None, help="The output path."
"-O",
"--output-path",
type=ResolvedPath(),
default=None,
help="The output path.",
)
@click.option(
"--watch",
Expand Down Expand Up @@ -159,7 +164,11 @@ def _build():

@cli.command("clean")
@click.option(
"-O", "--output-path", type=click.Path(), default=None, help="The output path."
"-O",
"--output-path",
type=ResolvedPath(),
default=None,
help="The output path.",
)
@click.option(
"-v",
Expand Down Expand Up @@ -195,7 +204,11 @@ def clean_cmd(ctx, output_path, verbosity, extra_flags):
@cli.command("deploy", short_help="Deploy the website.")
@click.argument("server", required=False)
@click.option(
"-O", "--output-path", type=click.Path(), default=None, help="The output path."
"-O",
"--output-path",
type=ResolvedPath(),
default=None,
help="The output path.",
)
@click.option(
"--username",
Expand Down Expand Up @@ -300,7 +313,7 @@ def deploy_cmd(ctx, server, output_path, extra_flags, **credentials):
@click.option(
"-O",
"--output-path",
type=click.Path(),
type=ResolvedPath(),
default=None,
help="The dev server will build into the same folder as "
"the build command by default.",
Expand Down
27 changes: 27 additions & 0 deletions lektor/cli_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# pylint: disable=import-outside-toplevel
from __future__ import annotations

import json
import os
from pathlib import Path
from typing import Any

import click

Expand Down Expand Up @@ -127,3 +131,26 @@ def validate_language(ctx, param, value):
if value is not None and not is_valid_language(value):
raise click.BadParameter('Unsupported language "%s".' % value)
return value


class ResolvedPath(click.Path):
"""A click paramter type for a resolved path.

We could just use ``click.Path(resolve_path=True)`` except that that
fails sometimes under Windows running python <= 3.9.

See https://github.com/pallets/click/issues/2466
"""

def __init__(self, writable=False, file_okay=True):
super().__init__(
resolve_path=True, allow_dash=False, writable=writable, file_okay=file_okay
)

def convert(
self, value: Any, param: click.Parameter | None, ctx: click.Context | None
) -> Any:
abspath = Path(value).absolute()
# fsdecode to ensure that the return value is a str.
# (with click<8.0.3 Path.convert will return Path if passed a Path)
return os.fsdecode(super().convert(abspath, param, ctx))
9 changes: 5 additions & 4 deletions lektor/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,13 @@ def project_path(self):

def get_output_path(self):
"""The path where output files are stored."""
config = self.open_config()
config = self.open_config() # raises if no project_file
output_path = config.get("project.output_path")
if output_path:
return os.path.join(self.tree, os.path.normpath(output_path))

return os.path.join(get_cache_dir(), "builds", self.id)
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
Expand Down
52 changes: 52 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import inspect
import json
import os
import re
from pathlib import Path

import pytest
from markers import imagemagick

from lektor.builder import Builder
from lektor.cli import cli
from lektor.devserver import run_server
from lektor.project import Project
from lektor.publisher import publish


def test_build_abort_in_existing_nonempty_dir(project_cli_runner):
Expand Down Expand Up @@ -136,3 +141,50 @@ def test_project_info_json(project_cli_runner):
project = Project.from_path(os.getcwd())
result = project_cli_runner.invoke(cli, ["project-info", "--json"])
assert json.loads(result.stdout) == project.to_json()


@pytest.fixture
def deployable_project_data(scratch_project_data):
project_file = next(scratch_project_data.glob("*.lektorproject"))
with project_file.open("a") as fp:
fp.write(
inspect.cleandoc(
"""[servers.default]
name = Default
target = rsync://example.net/
"""
)
)
return scratch_project_data


@pytest.mark.parametrize(
"subcommand, to_mock, param_name",
[
("build", Builder, "destination_path"),
("clean", Builder, "destination_path"),
("deploy", publish, "output_path"),
("server", run_server, "output_path"),
],
)
def test_build_output_path_relative_to_cwd(
project_cli_runner,
deployable_project_data,
mocker,
subcommand,
to_mock,
param_name,
):
mock = mocker.patch(f"{to_mock.__module__}.{to_mock.__qualname__}", autospec=True)
args = [
f"--project={deployable_project_data}",
subcommand,
"--output-path=htdocs",
]
project_cli_runner.invoke(cli, args, input="y\n")

args, kwargs = mock.call_args
bound_args = inspect.signature(to_mock).bind(*args, **kwargs).arguments
output_path = bound_args[param_name]
# NB: os.path.realpath does not resolve symlinks on Windows with Python==3.7
assert output_path == os.path.join(Path().resolve(), "htdocs")
21 changes: 21 additions & 0 deletions tests/test_cli_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import os
from pathlib import Path

import pytest

from lektor.cli_utils import ResolvedPath


@pytest.mark.parametrize("file_exists", [True, False])
def test_ResolvedPath_resolves_relative_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_exists: bool
) -> None:
resolved_path = ResolvedPath()
monkeypatch.chdir(tmp_path)
if file_exists:
Path(tmp_path, "filename").touch()

resolved = resolved_path.convert("filename", None, None)
assert isinstance(resolved, str)
# NB: os.path.realpath does not resolve symlinks on Windows with Python==3.7
assert resolved == os.path.join(Path().resolve(), "filename")
28 changes: 28 additions & 0 deletions tests/test_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import inspect
from pathlib import Path

from lektor.project import Project


def test_Project_get_output_path(tmp_path: Path) -> None:
project_file = tmp_path / "test.lektorproject"
project_file.touch()
project = Project.from_file(project_file)
assert Path(project.get_output_path()).parts[-2:] == ("builds", project.id)


def test_Project_get_output_path_is_relative_to_project_file(tmp_path: Path) -> None:
tree = tmp_path / "tree"
tree.mkdir()
project_file = tmp_path / "test.lektorproject"
project_file.write_text(
inspect.cleandoc(
"""[project]
path = tree
output_path = htdocs
"""
)
)

project = Project.from_file(project_file)
assert project.get_output_path() == str(tmp_path / "htdocs")