Skip to content

Commit 7a2a3f6

Browse files
authored
Merge pull request #1085 from carmenbianca/on-demand-project
Generate project object on demand
2 parents 3dd5742 + 6d9c844 commit 7a2a3f6

File tree

9 files changed

+71
-75
lines changed

9 files changed

+71
-75
lines changed

.github/workflows/gettext.yaml

+4
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ jobs:
2323
# exception to the branch protection, so we'll use that account's
2424
# token to push to the main branch.
2525
token: ${{ secrets.FSFE_SYSTEM_TOKEN }}
26+
- name: Set up Python
27+
uses: actions/setup-python@v2
28+
with:
29+
python-version: "3.9"
2630
- name: Install gettext and wlc
2731
run: sudo apt-get install -y gettext wlc
2832
# We mostly install reuse to install the click dependency.

src/reuse/cli/annotate.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
)
4545
from ..i18n import _
4646
from ..project import Project
47-
from .common import ClickObj, MutexOption, requires_project, spdx_identifier
47+
from .common import ClickObj, MutexOption, spdx_identifier
4848
from .main import main
4949

5050
_LOGGER = logging.getLogger(__name__)
@@ -285,7 +285,6 @@ def get_reuse_info(
285285
)
286286

287287

288-
@requires_project
289288
@main.command(name="annotate", help=_HELP)
290289
@click.option(
291290
"--copyright",
@@ -449,7 +448,7 @@ def annotate(
449448
paths: Sequence[Path],
450449
) -> None:
451450
# pylint: disable=too-many-arguments,too-many-locals,missing-function-docstring
452-
project = cast(Project, obj.project)
451+
project = obj.project
453452

454453
test_mandatory_option_required(copyrights, licenses, contributors)
455454
paths = all_paths(paths, recursive, project)

src/reuse/cli/common.py

+49-16
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,67 @@
44

55
"""Utilities that are common to multiple CLI commands."""
66

7-
from dataclasses import dataclass
8-
from typing import Any, Callable, Mapping, Optional, TypeVar
7+
from dataclasses import dataclass, field
8+
from pathlib import Path
9+
from typing import Any, Mapping, Optional
910

1011
import click
1112
from boolean.boolean import Expression, ParseError
1213
from license_expression import ExpressionError
1314

1415
from .._util import _LICENSING
16+
from ..global_licensing import GlobalLicensingParseError
1517
from ..i18n import _
16-
from ..project import Project
18+
from ..project import GlobalLicensingConflict, Project
19+
from ..vcs import find_root
1720

18-
F = TypeVar("F", bound=Callable)
1921

20-
21-
def requires_project(f: F) -> F:
22-
"""A decorator to mark subcommands that require a :class:`Project` object.
23-
Make sure to apply this decorator _first_.
24-
"""
25-
setattr(f, "requires_project", True)
26-
return f
27-
28-
29-
@dataclass(frozen=True)
22+
@dataclass()
3023
class ClickObj:
3124
"""A dataclass holding necessary context and options."""
3225

33-
no_multiprocessing: bool
34-
project: Optional[Project]
26+
root: Optional[Path] = None
27+
include_submodules: bool = False
28+
include_meson_subprojects: bool = False
29+
no_multiprocessing: bool = True
30+
31+
_project: Optional[Project] = field(
32+
default=None, init=False, repr=False, compare=False
33+
)
34+
35+
@property
36+
def project(self) -> Project:
37+
"""Generate a project object on demand, and cache it."""
38+
if self._project:
39+
return self._project
40+
41+
root = self.root
42+
if root is None:
43+
root = find_root()
44+
if root is None:
45+
root = Path.cwd()
46+
47+
try:
48+
project = Project.from_directory(
49+
root,
50+
include_submodules=self.include_submodules,
51+
include_meson_subprojects=self.include_meson_subprojects,
52+
)
53+
# FileNotFoundError and NotADirectoryError don't need to be caught
54+
# because argparse already made sure of these things.
55+
except GlobalLicensingParseError as error:
56+
raise click.UsageError(
57+
_(
58+
"'{path}' could not be parsed. We received the"
59+
" following error message: {message}"
60+
).format(path=error.source, message=str(error))
61+
) from error
62+
63+
except (GlobalLicensingConflict, OSError) as error:
64+
raise click.UsageError(str(error)) from error
65+
66+
self._project = project
67+
return project
3568

3669

3770
class MutexOption(click.Option):

src/reuse/cli/convert_dep5.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
from ..convert_dep5 import toml_from_dep5
1313
from ..global_licensing import ReuseDep5
1414
from ..i18n import _
15-
from ..project import Project
16-
from .common import ClickObj, requires_project
15+
from .common import ClickObj
1716
from .main import main
1817

1918
_HELP = _(
@@ -23,12 +22,11 @@
2322
)
2423

2524

26-
@requires_project
2725
@main.command(name="convert-dep5", help=_HELP)
2826
@click.pass_obj
2927
def convert_dep5(obj: ClickObj) -> None:
3028
# pylint: disable=missing-function-docstring
31-
project = cast(Project, obj.project)
29+
project = obj.project
3230
if not (project.root / ".reuse/dep5").exists():
3331
raise click.UsageError(_("No '.reuse/dep5' file."))
3432

src/reuse/cli/download.py

+3-7
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,17 @@
99
import sys
1010
from difflib import SequenceMatcher
1111
from pathlib import Path
12-
from typing import IO, Collection, Optional, cast
12+
from typing import IO, Collection, Optional
1313
from urllib.error import URLError
1414

1515
import click
1616

1717
from .._licenses import ALL_NON_DEPRECATED_MAP
1818
from ..download import _path_to_license_file, put_license_in_file
1919
from ..i18n import _
20-
from ..project import Project
2120
from ..report import ProjectReport
2221
from ..types import StrPath
23-
from .common import ClickObj, MutexOption, requires_project
22+
from .common import ClickObj, MutexOption
2423
from .main import main
2524

2625
_LOGGER = logging.getLogger(__name__)
@@ -113,7 +112,6 @@ def _successfully_downloaded(destination: StrPath) -> None:
113112
)
114113

115114

116-
@requires_project
117115
@main.command(name="download", help=_HELP)
118116
@click.option(
119117
"--all",
@@ -166,9 +164,7 @@ def download(
166164

167165
if all_:
168166
# TODO: This is fairly inefficient, but gets the job done.
169-
report = ProjectReport.generate(
170-
cast(Project, obj.project), do_checksum=False
171-
)
167+
report = ProjectReport.generate(obj.project, do_checksum=False)
172168
licenses = report.missing_licenses.keys()
173169

174170
if len(licenses) > 1 and output:

src/reuse/cli/lint.py

+2-5
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,14 @@
1010
"""Click code for lint subcommand."""
1111

1212
import sys
13-
from typing import cast
1413

1514
import click
1615

1716
from .. import __REUSE_version__
1817
from ..i18n import _
1918
from ..lint import format_json, format_lines, format_plain
20-
from ..project import Project
2119
from ..report import ProjectReport
22-
from .common import ClickObj, MutexOption, requires_project
20+
from .common import ClickObj, MutexOption
2321
from .main import main
2422

2523
_OUTPUT_MUTEX = ["quiet", "json", "plain", "lines"]
@@ -62,7 +60,6 @@
6260
)
6361

6462

65-
@requires_project
6663
@main.command(name="lint", help=_HELP)
6764
@click.option(
6865
"--quiet",
@@ -102,7 +99,7 @@ def lint(
10299
) -> None:
103100
# pylint: disable=missing-function-docstring
104101
report = ProjectReport.generate(
105-
cast(Project, obj.project),
102+
obj.project,
106103
do_checksum=False,
107104
multiprocessing=not obj.no_multiprocessing,
108105
)

src/reuse/cli/lint_file.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@
99

1010
import sys
1111
from pathlib import Path
12-
from typing import Collection, cast
12+
from typing import Collection
1313

1414
import click
1515

1616
from ..i18n import _
1717
from ..lint import format_lines_subset
18-
from ..project import Project
1918
from ..report import ProjectSubsetReport
20-
from .common import ClickObj, MutexOption, requires_project
19+
from .common import ClickObj, MutexOption
2120
from .main import main
2221

2322
_OUTPUT_MUTEX = ["quiet", "lines"]
@@ -29,7 +28,6 @@
2928
)
3029

3130

32-
@requires_project
3331
@main.command(name="lint-file", help=_HELP)
3432
@click.option(
3533
"--quiet",
@@ -58,7 +56,7 @@ def lint_file(
5856
obj: ClickObj, quiet: bool, lines: bool, files: Collection[Path]
5957
) -> None:
6058
# pylint: disable=missing-function-docstring
61-
project = cast(Project, obj.project)
59+
project = obj.project
6260
subset_files = {Path(file_) for file_ in files}
6361
for file_ in subset_files:
6462
if not file_.resolve().is_relative_to(project.root.resolve()):

src/reuse/cli/main.py

+3-30
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,7 @@
2121

2222
from .. import __REUSE_version__
2323
from .._util import setup_logging
24-
from ..global_licensing import GlobalLicensingParseError
2524
from ..i18n import _
26-
from ..project import GlobalLicensingConflict, Project
27-
from ..vcs import find_root
2825
from .common import ClickObj
2926

3027
_PACKAGE_PATH = os.path.dirname(__file__)
@@ -146,33 +143,9 @@ def main(
146143
if not suppress_deprecation:
147144
warnings.filterwarnings("default", module="reuse")
148145

149-
project: Optional[Project] = None
150-
if ctx.invoked_subcommand:
151-
cmd = main.get_command(ctx, ctx.invoked_subcommand)
152-
if getattr(cmd, "requires_project", False):
153-
if root is None:
154-
root = find_root()
155-
if root is None:
156-
root = Path.cwd()
157-
158-
try:
159-
project = Project.from_directory(root)
160-
# FileNotFoundError and NotADirectoryError don't need to be caught
161-
# because argparse already made sure of these things.
162-
except GlobalLicensingParseError as error:
163-
raise click.UsageError(
164-
_(
165-
"'{path}' could not be parsed. We received the"
166-
" following error message: {message}"
167-
).format(path=error.source, message=str(error))
168-
) from error
169-
170-
except (GlobalLicensingConflict, OSError) as error:
171-
raise click.UsageError(str(error)) from error
172-
project.include_submodules = include_submodules
173-
project.include_meson_subprojects = include_meson_subprojects
174-
175146
ctx.obj = ClickObj(
147+
root=root,
148+
include_submodules=include_submodules,
149+
include_meson_subprojects=include_meson_subprojects,
176150
no_multiprocessing=no_multiprocessing,
177-
project=project,
178151
)

src/reuse/cli/spdx.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,21 @@
88
import contextlib
99
import logging
1010
import sys
11-
from typing import Optional, cast
11+
from typing import Optional
1212

1313
import click
1414

1515
from .. import _IGNORE_SPDX_PATTERNS
1616
from ..i18n import _
17-
from ..project import Project
1817
from ..report import ProjectReport
19-
from .common import ClickObj, requires_project
18+
from .common import ClickObj
2019
from .main import main
2120

2221
_LOGGER = logging.getLogger(__name__)
2322

2423
_HELP = _("Generate an SPDX bill of materials.")
2524

2625

27-
@requires_project
2826
@main.command(name="spdx", help=_HELP)
2927
@click.option(
3028
"--output",
@@ -103,7 +101,7 @@ def spdx(
103101
)
104102

105103
report = ProjectReport.generate(
106-
cast(Project, obj.project),
104+
obj.project,
107105
multiprocessing=not obj.no_multiprocessing,
108106
add_license_concluded=add_license_concluded,
109107
)

0 commit comments

Comments
 (0)