Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -419,18 +419,14 @@ def is_project_initialized(cls, ge_dir: PathStr) -> bool:
@classmethod
def is_project_scaffolded(cls, ge_dir: PathStr) -> bool:
"""
Return True if the project is scaffolded (required filesystem changes have occurred).
Return True if the project is already set up and must not be re-scaffolded.

To be considered scaffolded, all of the following must be true:
- all project directories exist (including uncommitted directories)
- a valid great_expectations.yml is on disk
- a config_variables.yml is on disk
A project is considered set up when a great_expectations.yml is present on disk.
Version-control-ignored runtime-output directories (uncommitted/*) are intentionally
not required: they are absent on a clean checkout and are created on demand at write
time.
"""
return (
cls.does_config_exist_on_disk(ge_dir)
and cls.all_uncommitted_directories_exist(ge_dir)
and cls.config_variables_yml_exist(ge_dir)
)
return cls.does_config_exist_on_disk(ge_dir)

@classmethod
def _does_project_have_a_datasource_in_config_file(cls, ge_dir: PathStr) -> bool:
Expand Down
26 changes: 22 additions & 4 deletions great_expectations/data_context/store/tuple_store_backend.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# PYTHON 2 - py2 - update to ABC direct use rather than __metaclass__ once we drop py2 support
from __future__ import annotations

import errno
import logging
import os
import pathlib
Expand Down Expand Up @@ -264,10 +265,27 @@ def __init__( # noqa: PLR0913 # FIXME CoP
root_directory, base_directory
)

os.makedirs( # noqa: PTH103 # FIXME CoP
str(os.path.dirname(self.full_base_directory)), # noqa: PTH120 # FIXME CoP
exist_ok=True,
)
# The parent directory is created eagerly here for convenience only: _set and _move
# (below) create their own leaf directory before writing, so this eager creation is
# redundant for an actual write. On a read-only filesystem it must not abort
# construction: reads require no directory, and a genuine write still raises clearly
# later at write time. The tolerance is narrowed to the two read-only signals so a real
# fault on a writable filesystem (e.g. out-of-space, a path collision) still fails fast
# at construction, exactly as before:
# - read-only mount -> OSError with errno EROFS
# - read-only dir -> PermissionError (EACCES/EPERM), a subclass of OSError
try:
os.makedirs( # noqa: PTH103 # FIXME CoP
str(os.path.dirname(self.full_base_directory)), # noqa: PTH120 # FIXME CoP
exist_ok=True,
)
except OSError as e:
if not isinstance(e, PermissionError) and e.errno != errno.EROFS:
raise
logger.debug(
f"Could not pre-create directory for store backend at "
f"{self.full_base_directory}: {e}. It will be created on first write."
)
# Initialize with store_backend_id if not part of an HTMLSiteStore
if not self._suppress_store_backend_id:
_ = self.store_backend_id
Expand Down
65 changes: 65 additions & 0 deletions tests/data_context/store/test_store_backends.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import errno
import json
import os
import uuid
Expand Down Expand Up @@ -225,6 +226,70 @@ def test_tuple_filesystem_store_filepath_prefix_error(tmp_path_factory):
assert "filepath_prefix may not end with" in e.value.message


@pytest.mark.filesystem
def test_TupleFilesystemStoreBackend_construction_tolerates_read_only_parent_directory(
tmp_path,
):
"""Constructing a store backend whose parent directory cannot be created because it sits
under a read-only directory must not raise: the directory is only needed at write time, and
TupleFilesystemStoreBackend._set/_move re-create their own leaf directory before writing, so
a genuine write against a read-only filesystem still fails clearly later.
""" # FIXME CoP
readonly_parent = tmp_path / "readonly_parent"
readonly_parent.mkdir()
base_directory = readonly_parent / "nested" / "leaf"

readonly_parent.chmod(0o555)
try:
# Some environments (root, or filesystems that ignore mode bits) do not enforce
# directory write permissions; skip cleanly rather than false-failing there.
probe = readonly_parent / "write_probe"
try:
probe.mkdir()
except OSError:
pass
else:
probe.rmdir()
pytest.skip("directory permissions are not enforced in this environment")

# suppress_store_backend_id avoids a second, later write (the store_backend_id file)
# that would also hit the read-only parent -- this test targets only the eager
# directory-creation guard exercised directly by __init__.
store_backend = TupleFilesystemStoreBackend(
base_directory=str(base_directory),
suppress_store_backend_id=True,
)

assert store_backend.full_base_directory == str(base_directory)
# The guard defers creation rather than forcing it -- the leaf directory was never made.
assert not base_directory.parent.exists()
finally:
readonly_parent.chmod(0o755)


@pytest.mark.filesystem
def test_TupleFilesystemStoreBackend_construction_reraises_non_read_only_oserror(tmp_path):
"""A genuine non-read-only failure (e.g. out of disk space) during the eager parent-directory
creation must still raise at construction, exactly as before the read-only tolerance was
added.
""" # FIXME CoP
base_directory = tmp_path / "store"

def raise_enospc(*args, **kwargs):
raise OSError(errno.ENOSPC, "No space left on device")

with mock.patch(
"great_expectations.data_context.store.tuple_store_backend.os.makedirs",
side_effect=raise_enospc,
):
with pytest.raises(OSError) as exc_info: # FIXME CoP
TupleFilesystemStoreBackend(
base_directory=str(base_directory),
suppress_store_backend_id=True,
)
assert exc_info.value.errno == errno.ENOSPC


@pytest.mark.filesystem
def test_FilesystemStoreBackend_two_way_string_conversion(tmp_path_factory):
path = str(
Expand Down
42 changes: 34 additions & 8 deletions tests/data_context/test_data_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,31 @@ def test_data_context_is_project_scaffolded(empty_context):
assert FileDataContext.is_project_scaffolded(ge_dir) is True


@pytest.mark.filesystem
def test_data_context_is_project_scaffolded_true_when_uncommitted_dirs_absent(empty_context):
"""A committed great_expectations.yml is sufficient, even when the gitignored
uncommitted/* runtime-output directories are absent, e.g. on a fresh version-control
checkout.
"""
ge_dir = empty_context.root_directory
shutil.rmtree(os.path.join(ge_dir, "uncommitted")) # noqa: PTH118 # FIXME CoP

assert FileDataContext.does_config_exist_on_disk(ge_dir) is True
assert not FileDataContext.all_uncommitted_directories_exist(ge_dir)
assert FileDataContext.is_project_scaffolded(ge_dir) is True


@pytest.mark.filesystem
def test_data_context_is_project_scaffolded_false_when_no_config(empty_context):
"""A project root with no great_expectations.yml on disk must not be treated as
already set up.
"""
ge_dir = empty_context.root_directory
safe_remove(os.path.join(ge_dir, empty_context.GX_YML)) # noqa: PTH118 # FIXME CoP

assert FileDataContext.is_project_scaffolded(ge_dir) is False


@pytest.mark.filesystem
def test_data_context_does_ge_yml_exist_returns_true_when_it_does_exist(empty_context):
ge_dir = empty_context.root_directory
Expand Down Expand Up @@ -381,24 +406,27 @@ def test_data_context_create_raises_warning_and_leaves_existing_yml_untouched(


@pytest.mark.filesystem
def test_data_context_create_makes_uncommitted_dirs_when_all_are_missing(
def test_data_context_recognizes_existing_project_and_does_not_recreate_uncommitted_dirs(
tmp_path_factory,
):
"""A project root carrying a committed great_expectations.yml is recognized as already
set up even when its gitignored uncommitted/* directories are absent (e.g. a fresh
version-control checkout) - re-running create must not perform a destructive re-scaffold
of the whole project. The one exception is the validation-results store's own directory,
which is (re)created lazily, independent of the scaffold decision.
"""
project_path = str(tmp_path_factory.mktemp("data_context"))
gx.get_context(mode="file", project_root_dir=project_path)

# mangle the existing setup
# mangle the existing setup, simulating a fresh checkout where uncommitted/ is gitignored
ge_dir = os.path.join(project_path, FileDataContext.GX_DIR) # noqa: PTH118 # FIXME CoP
uncommitted_dir = os.path.join(ge_dir, "uncommitted") # noqa: PTH118 # FIXME CoP
shutil.rmtree(uncommitted_dir)

# re-run create to simulate onboarding
# re-run create against the existing, committed project
gx.get_context(mode="file", project_root_dir=project_path)
obs = gen_directory_tree_str(ge_dir)

assert os.path.isdir( # noqa: PTH112 # FIXME CoP
uncommitted_dir
), "No uncommitted directory created"
assert (
obs
== """\
Expand All @@ -415,8 +443,6 @@ def test_data_context_create_makes_uncommitted_dirs_when_all_are_missing(
data_docs_custom_styles.css
views/
uncommitted/
config_variables.yml
data_docs/
validations/
.ge_store_backend_id
validation_definitions/
Expand Down
Loading
Loading