Skip to content

Commit 9e43604

Browse files
authored
Notify the user of ignored requirements (#15799)
1 parent 3a99a25 commit 9e43604

File tree

11 files changed

+189
-111
lines changed

11 files changed

+189
-111
lines changed

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,6 @@ module = [
135135
"lightning_app.utilities.name_generator",
136136
"lightning_app.utilities.network",
137137
"lightning_app.utilities.openapi",
138-
"lightning_app.utilities.packaging.build_config",
139138
"lightning_app.utilities.packaging.cloud_compute",
140139
"lightning_app.utilities.packaging.docker",
141140
"lightning_app.utilities.packaging.lightning_utils",

src/lightning_app/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
1010

1111
- Added the CLI command `lightning run model` to launch a `LightningLite` accelerated script ([#15506](https://github.com/Lightning-AI/lightning/pull/15506))
1212

13+
- Show a message when `BuildConfig(requirements=[...])` is passed but a `requirements.txt` file is already present in the Work ([#15799](https://github.com/Lightning-AI/lightning/pull/15799))
14+
- Show a message when `BuildConfig(dockerfile="...")` is passed but a `Dockerfile` file is already present in the Work ([#15799](https://github.com/Lightning-AI/lightning/pull/15799))
1315

1416
### Changed
1517

src/lightning_app/runners/cloud.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import fnmatch
22
import json
3-
import os
43
import random
54
import string
65
import sys
@@ -50,6 +49,7 @@
5049
CLOUD_UPLOAD_WARNING,
5150
DEFAULT_NUMBER_OF_EXPOSED_PORTS,
5251
DISABLE_DEPENDENCY_CACHE,
52+
DOT_IGNORE_FILENAME,
5353
ENABLE_APP_COMMENT_COMMAND_EXECUTION,
5454
ENABLE_MULTIPLE_WORKS_IN_DEFAULT_CONTAINER,
5555
ENABLE_MULTIPLE_WORKS_IN_NON_DEFAULT_CONTAINER,
@@ -488,12 +488,12 @@ def _check_uploaded_folder(root: Path, repo: LocalSourceCodeDir) -> None:
488488
largest_paths = sorted((x for x in path_sizes if x[-1] > 0.01), key=lambda x: x[1], reverse=True)[:25]
489489
largest_paths_msg = "\n".join(f"{round(s, 5)} MB: {p}" for p, s in largest_paths)
490490
warning_msg = (
491-
f"Your application folder {root} is more than {CLOUD_UPLOAD_WARNING} MB. "
492-
f"Found {app_folder_size_in_mb} MB \n"
493-
"Here are the largest files: \n"
494-
f"{largest_paths_msg}"
491+
f"Your application folder '{root.absolute()}' is more than {CLOUD_UPLOAD_WARNING} MB. "
492+
f"The total size is {app_folder_size_in_mb} MB\n"
493+
f"Here are the largest files: \n{largest_paths_msg}\n"
494+
"Perhaps you should try running the app in an empty directory."
495495
)
496-
if not os.path.exists(os.path.join(root, ".lightningignore")):
496+
if not (root / DOT_IGNORE_FILENAME).is_file():
497497
warning_msg = (
498498
warning_msg
499499
+ "\nIn order to ignore some files or folder, "

src/lightning_app/source_code/copytree.py

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fnmatch
22
import os
3+
from functools import partial
34
from pathlib import Path
45
from shutil import copy2, copystat, Error
56
from typing import Callable, List, Set, Union
@@ -58,8 +59,10 @@ def _copytree(
5859
_ignore_filename_spell_check(src)
5960
src = Path(src)
6061
dst = Path(dst)
61-
if src.joinpath(DOT_IGNORE_FILENAME).exists():
62-
ignore_fn = _get_ignore_function(src)
62+
ignore_filepath = src / DOT_IGNORE_FILENAME
63+
if ignore_filepath.is_file():
64+
patterns = _read_lightningignore(ignore_filepath)
65+
ignore_fn = partial(_filter_ignored, src, patterns)
6366
# creating new list so we won't modify the original
6467
ignore_functions = [*ignore_functions, ignore_fn]
6568

@@ -108,19 +111,22 @@ def _copytree(
108111
return files_copied
109112

110113

111-
def _get_ignore_function(src: Path) -> Callable:
112-
patterns = _read_lightningignore(src / DOT_IGNORE_FILENAME)
114+
def _filter_ignored(src: Path, patterns: Set[str], current_dir: Path, entries: List[Path]) -> List[Path]:
115+
relative_dir = current_dir.relative_to(src)
116+
names = [str(relative_dir / entry.name) for entry in entries]
117+
ignored_names = set()
118+
for pattern in patterns:
119+
ignored_names.update(fnmatch.filter(names, pattern))
120+
return [entry for entry in entries if str(relative_dir / entry.name) not in ignored_names]
113121

114-
def filter_ignored(current_dir: Path, entries: List[Path]) -> List[Path]:
115-
relative_dir = current_dir.relative_to(src)
116-
names = [str(relative_dir / entry.name) for entry in entries]
117-
ignored_names = []
118-
for pattern in patterns:
119-
ignored_names.extend(fnmatch.filter(names, pattern))
120-
ignored_names_set = set(ignored_names)
121-
return [entry for entry in entries if str(relative_dir / entry.name) not in ignored_names_set]
122122

123-
return filter_ignored
123+
def _parse_lightningignore(lines: List[str]) -> Set[str]:
124+
"""Creates a set that removes empty lines and comments."""
125+
lines = [ln.strip() for ln in lines]
126+
# removes first `/` character for posix and `\\` for windows
127+
lines = [ln.lstrip("/").lstrip("\\") for ln in lines if ln != "" and not ln.startswith("#")]
128+
# convert to path and converting back to string to sanitize the pattern
129+
return {str(Path(ln)) for ln in lines}
124130

125131

126132
def _read_lightningignore(path: Path) -> Set[str]:
@@ -137,14 +143,8 @@ def _read_lightningignore(path: Path) -> Set[str]:
137143
Set[str]
138144
Set of unique lines.
139145
"""
140-
raw_lines = [ln.strip() for ln in path.open().readlines()]
141-
142-
# creates a set that removes empty lines and comments
143-
lines = {ln for ln in raw_lines if ln != "" and ln is not None and not ln.startswith("#")}
144-
145-
# removes first `/` character for posix and `\\` for windows
146-
# also converting to path and converting back to string to sanitize the pattern
147-
return {str(Path(ln.lstrip("/").lstrip("\\"))) for ln in lines}
146+
raw_lines = path.open().readlines()
147+
return _parse_lightningignore(raw_lines)
148148

149149

150150
def _ignore_filename_spell_check(src: Path):
Lines changed: 85 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import inspect
22
import os
33
import re
4-
from dataclasses import asdict, dataclass
5-
from types import FrameType
6-
from typing import cast, List, Optional, TYPE_CHECKING, Union
4+
from dataclasses import asdict, dataclass, field
5+
from pathlib import Path
6+
from typing import Dict, List, Optional, Union
77

8+
from typing_extensions import Self
9+
10+
import lightning_app as L
811
from lightning_app.utilities.app_helpers import Logger
912
from lightning_app.utilities.packaging.cloud_compute import CloudCompute
1013

11-
if TYPE_CHECKING:
12-
from lightning_app import LightningWork
13-
1414
logger = Logger(__name__)
1515

1616

@@ -19,11 +19,10 @@ def load_requirements(
1919
) -> List[str]:
2020
"""Load requirements from a file.
2121
22-
.. code-block:: python
23-
24-
path_req = os.path.join(_PROJECT_ROOT, "requirements")
25-
requirements = load_requirements(path_req)
26-
print(requirements) # ['numpy...', 'torch...', ...]
22+
>>> from lightning_app import _PROJECT_ROOT
23+
>>> path_req = os.path.join(_PROJECT_ROOT, "requirements")
24+
>>> load_requirements(path_req, "docs.txt") # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE +SKIP
25+
['sphinx>=4.0', ...] # TODO: remove SKIP, fails on python 3.7
2726
"""
2827
path = os.path.join(path_dir, file_name)
2928
if not os.path.isfile(path):
@@ -49,12 +48,19 @@ def load_requirements(
4948
return reqs
5049

5150

51+
@dataclass
52+
class _Dockerfile:
53+
path: str
54+
data: List[str]
55+
56+
5257
@dataclass
5358
class BuildConfig:
5459
"""The Build Configuration describes how the environment a LightningWork runs in should be set up.
5560
5661
Arguments:
57-
requirements: List of requirements or paths to requirement files.
62+
requirements: List of requirements or list of paths to requirement files. If not passed, they will be
63+
automatically extracted from a `requirements.txt` if it exists.
5864
dockerfile: The path to a dockerfile to be used to build your container.
5965
You need to add those lines to ensure your container works in the cloud.
6066
@@ -64,20 +70,19 @@ class BuildConfig:
6470
6571
WORKDIR /gridai/project
6672
COPY . .
67-
68-
Learn more by checking out:
69-
https://docs.grid.ai/features/runs/running-experiments-with-a-dockerfile
7073
image: The base image that the work runs on. This should be a publicly accessible image from a registry that
7174
doesn't enforce rate limits (such as DockerHub) to pull this image, otherwise your application will not
7275
start.
7376
"""
7477

75-
requirements: Optional[Union[str, List[str]]] = None
76-
dockerfile: Optional[str] = None
78+
requirements: List[str] = field(default_factory=list)
79+
dockerfile: Optional[Union[str, Path, _Dockerfile]] = None
7780
image: Optional[str] = None
7881

79-
def __post_init__(self):
80-
self._call_dir = os.path.dirname(cast(FrameType, inspect.currentframe()).f_back.f_back.f_code.co_filename)
82+
def __post_init__(self) -> None:
83+
current_frame = inspect.currentframe()
84+
co_filename = current_frame.f_back.f_back.f_code.co_filename # type: ignore[union-attr]
85+
self._call_dir = os.path.dirname(co_filename)
8186
self._prepare_requirements()
8287
self._prepare_dockerfile()
8388

@@ -101,76 +106,90 @@ def build_commands(self):
101106
"""
102107
return []
103108

104-
def on_work_init(self, work, cloud_compute: Optional["CloudCompute"] = None):
109+
def on_work_init(self, work: "L.LightningWork", cloud_compute: Optional["CloudCompute"] = None) -> None:
105110
"""Override with your own logic to load the requirements or dockerfile."""
106-
try:
107-
self.requirements = sorted(self.requirements or self._find_requirements(work) or [])
108-
self.dockerfile = self.dockerfile or self._find_dockerfile(work)
109-
except TypeError:
110-
logger.debug("The provided work couldn't be found.")
111+
found_requirements = self._find_requirements(work)
112+
if self.requirements:
113+
if found_requirements and self.requirements != found_requirements:
114+
# notify the user of this silent behaviour
115+
logger.info(
116+
f"A 'requirements.txt' exists with {found_requirements} but {self.requirements} was passed to"
117+
f" the `{type(self).__name__}` in {work.name!r}. The `requirements.txt` file will be ignored."
118+
)
119+
else:
120+
self.requirements = found_requirements
121+
self._prepare_requirements()
111122

112-
def _find_requirements(self, work: "LightningWork") -> List[str]:
113-
# 1. Get work file
114-
file = inspect.getfile(work.__class__)
123+
found_dockerfile = self._find_dockerfile(work)
124+
if self.dockerfile:
125+
if found_dockerfile and self.dockerfile != found_dockerfile:
126+
# notify the user of this silent behaviour
127+
logger.info(
128+
f"A Dockerfile exists at {found_dockerfile!r} but {self.dockerfile!r} was passed to"
129+
f" the `{type(self).__name__}` in {work.name!r}. {found_dockerfile!r}` will be ignored."
130+
)
131+
else:
132+
self.dockerfile = found_dockerfile
133+
self._prepare_dockerfile()
115134

116-
# 2. Try to find a requirement file associated the file.
117-
dirname = os.path.dirname(file) or "."
118-
requirement_files = [os.path.join(dirname, f) for f in os.listdir(dirname) if f == "requirements.txt"]
119-
if not requirement_files:
135+
def _find_requirements(self, work: "L.LightningWork", filename: str = "requirements.txt") -> List[str]:
136+
# 1. Get work file
137+
file = _get_work_file(work)
138+
if file is None:
120139
return []
121-
dirname, basename = os.path.dirname(requirement_files[0]), os.path.basename(requirement_files[0])
140+
# 2. Try to find a requirement file associated the file.
141+
dirname = os.path.dirname(file)
122142
try:
123-
requirements = load_requirements(dirname, basename)
143+
requirements = load_requirements(dirname, filename)
124144
except NotADirectoryError:
125-
requirements = []
145+
return []
126146
return [r for r in requirements if r != "lightning"]
127147

128-
def _find_dockerfile(self, work: "LightningWork") -> List[str]:
148+
def _find_dockerfile(self, work: "L.LightningWork", filename: str = "Dockerfile") -> Optional[str]:
129149
# 1. Get work file
130-
file = inspect.getfile(work.__class__)
131-
132-
# 2. Check for Dockerfile.
133-
dirname = os.path.dirname(file) or "."
134-
dockerfiles = [os.path.join(dirname, f) for f in os.listdir(dirname) if f == "Dockerfile"]
135-
136-
if not dockerfiles:
137-
return []
138-
139-
# 3. Read the dockerfile
140-
with open(dockerfiles[0]) as f:
141-
dockerfile = list(f.readlines())
142-
return dockerfile
143-
144-
def _prepare_requirements(self) -> Optional[Union[str, List[str]]]:
145-
if not self.requirements:
150+
file = _get_work_file(work)
151+
if file is None:
146152
return None
153+
# 2. Check for Dockerfile.
154+
dirname = os.path.dirname(file)
155+
dockerfile = os.path.join(dirname, filename)
156+
if os.path.isfile(dockerfile):
157+
return dockerfile
147158

159+
def _prepare_requirements(self) -> None:
148160
requirements = []
149161
for req in self.requirements:
150162
# 1. Check for relative path
151163
path = os.path.join(self._call_dir, req)
152-
if os.path.exists(path):
164+
if os.path.isfile(path):
153165
try:
154-
requirements.extend(
155-
load_requirements(os.path.dirname(path), os.path.basename(path)),
156-
)
166+
new_requirements = load_requirements(self._call_dir, req)
157167
except NotADirectoryError:
158-
pass
168+
continue
169+
requirements.extend(new_requirements)
159170
else:
160171
requirements.append(req)
161-
162172
self.requirements = requirements
163173

164-
def _prepare_dockerfile(self):
165-
if self.dockerfile:
166-
dockerfile_path = os.path.join(self._call_dir, self.dockerfile)
167-
if os.path.exists(dockerfile_path):
168-
with open(dockerfile_path) as f:
169-
self.dockerfile = list(f.readlines())
174+
def _prepare_dockerfile(self) -> None:
175+
if isinstance(self.dockerfile, (str, Path)):
176+
path = os.path.join(self._call_dir, self.dockerfile)
177+
if os.path.exists(path):
178+
with open(path) as f:
179+
self.dockerfile = _Dockerfile(path, f.readlines())
170180

171-
def to_dict(self):
181+
def to_dict(self) -> Dict:
172182
return {"__build_config__": asdict(self)}
173183

174184
@classmethod
175-
def from_dict(cls, d):
185+
def from_dict(cls, d: Dict) -> Self: # type: ignore[valid-type]
176186
return cls(**d["__build_config__"])
187+
188+
189+
def _get_work_file(work: "L.LightningWork") -> Optional[str]:
190+
cls = work.__class__
191+
try:
192+
return inspect.getfile(cls)
193+
except TypeError:
194+
logger.debug(f"The {cls.__name__} file couldn't be found.")
195+
return None

tests/tests_app/cli/test_run_app.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,9 @@
1111
from lightning_app import LightningApp
1212
from lightning_app.cli.lightning_cli import _run_app, run_app
1313
from lightning_app.runners.runtime_type import RuntimeType
14-
from lightning_app.testing.helpers import _RunIf
1514
from lightning_app.utilities.app_helpers import convert_print_to_logger_info
1615

1716

18-
@_RunIf(skip_linux=True)
1917
@mock.patch("click.launch")
2018
@pytest.mark.parametrize("open_ui", (True, False))
2119
def test_lightning_run_app(lauch_mock: mock.MagicMock, open_ui, caplog, monkeypatch):
@@ -51,7 +49,7 @@ def _lightning_app_run_and_logging(self, *args, **kwargs):
5149
else:
5250
lauch_mock.assert_not_called()
5351
assert result.exit_code == 0
54-
assert len(caplog.messages) == 2
52+
assert len(caplog.messages) == 4
5553
assert bool(int(caplog.messages[0])) is open_ui
5654

5755

tests/tests_app/conftest.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,30 @@ def another_tmpdir(tmp_path: Path) -> py.path.local:
7373
random_dir = datetime.now().strftime("%m-%d-%Y-%H-%M-%S")
7474
tmp_path = os.path.join(tmp_path, random_dir)
7575
return py.path.local(tmp_path)
76+
77+
78+
@pytest.fixture
79+
def caplog(caplog):
80+
"""Workaround for https://github.com/pytest-dev/pytest/issues/3697.
81+
82+
Setting ``filterwarnings`` with pytest breaks ``caplog`` when ``not logger.propagate``.
83+
"""
84+
import logging
85+
86+
root_logger = logging.getLogger()
87+
root_propagate = root_logger.propagate
88+
root_logger.propagate = True
89+
90+
propagation_dict = {
91+
name: logging.getLogger(name).propagate
92+
for name in logging.root.manager.loggerDict
93+
if name.startswith("lightning_app")
94+
}
95+
for name in propagation_dict.keys():
96+
logging.getLogger(name).propagate = True
97+
98+
yield caplog
99+
100+
root_logger.propagate = root_propagate
101+
for name, propagate in propagation_dict.items():
102+
logging.getLogger(name).propagate = propagate

0 commit comments

Comments
 (0)