diff --git a/src/textual/validation.py b/src/textual/validation.py index 438016b1a3..291d2078fb 100644 --- a/src/textual/validation.py +++ b/src/textual/validation.py @@ -10,8 +10,10 @@ import math import re +import os from abc import ABC, abstractmethod from dataclasses import dataclass, field +from pathlib import Path from typing import Callable, Pattern, Sequence from urllib.parse import urlparse @@ -355,7 +357,7 @@ def validate(self, value: str) -> ValidationResult: # We know it's a number, but is that number an integer? try: - int_value = int(value) + int(value) except ValueError: return ValidationResult.failure([Integer.NotAnInteger(self, value)]) return self.success() @@ -511,3 +513,208 @@ def describe_failure(self, failure: Failure) -> str | None: A string description of the failure. """ return "Must be a valid URL." + + +class File(Validator): + def __init__( + self, + must_exists: bool = False, + must_not_exists: bool = False, + required_permissions: str | None = None, + extensions: str | list[str] | None = None, + failure_description: str | None = None, + ) -> None: + """ + Initialize the file validator. + + :param must_exist: Specify if the file must exist. + :param must__not_exist: Specify if the file must not exist. + :param required_permissions: File permission to check (e.g., "r", "w", "x", "rwx"). + :param extensions: Expected file extension (e.g., ".txt"). + """ + super().__init__(failure_description=failure_description) + self.must_exists = must_exists + self.must_not_exists = must_not_exists + if self.must_exists and self.must_not_exists: + raise ValueError( + "Conflicts on existence contraints, a path must exists or not" + ) from None + self.required_permissions = required_permissions or "" + self.extensions = [extensions] if isinstance(extensions, str) else extensions + + class NotAFile(Failure): + """Indicate that the path is not a file.""" + + class DoesNotExists(Failure): + """Indicate that the file does not exists.""" + + class AlreadyExists(Failure): + """Indicate that the file exists and should not.""" + + class ExtensionFileNotSupported(Failure): + """Indicate that the file exists and should not.""" + + class IsNotReadable(Failure): + """Indicate that the file is not readable.""" + + class IsNotWritable(Failure): + """Indicate that the file is not writable.""" + + class IsNotExecutable(Failure): + """Indicate that the file is not executable.""" + + def validate(self, value: str) -> ValidationResult: + """Check if the given path meets all file requirements.""" + file_path = Path(value) + + # Check if path is a file + if file_path.exists() and not file_path.is_file(): + return ValidationResult.failure([File.NotAFile(self, value)]) + + # Check existence + if self.must_exists and not file_path.exists(): + return ValidationResult.failure([File.DoesNotExists(self, value)]) + if self.must_not_exists and file_path.exists(): + return ValidationResult.failure([File.AlreadyExists(self, value)]) + + # Check file permissions + if file_path.exists(): + if "r" in self.required_permissions: + if not os.access(file_path, mode=os.R_OK): + return ValidationResult.failure([File.IsNotReadable(self, value)]) + if "w" in self.required_permissions: + if not os.access(file_path, mode=os.W_OK): + return ValidationResult.failure([File.IsNotWritable(self, value)]) + if "x" in self.required_permissions: + if not os.access(file_path, mode=os.X_OK): + return ValidationResult.failure([File.IsNotExecutable(self, value)]) + + # Check file extension + if self.extensions and file_path.suffix not in self.extensions: + return ValidationResult.failure( + [File.ExtensionFileNotSupported(self, value)] + ) + + return self.success() + + def describe_failure(self, failure: Failure) -> str | None: + """Describes why the validator failed. + + Args: + failure: Information about why the validation failed. + + Returns: + A string description of the failure. + """ + if isinstance(failure, File.NotAFile): + return "Path is not a file" + elif isinstance(failure, File.DoesNotExists): + return "File does not exist" + elif isinstance(failure, File.AlreadyExists): + return "File does already exist" + elif isinstance(failure, File.IsNotReadable): + return "File is not readable by this user" + elif isinstance(failure, File.IsNotWritable): + return "File is not writable by this user" + elif isinstance(failure, File.IsNotExecutable): + return "File is not executable by this user" + elif isinstance(failure, File.ExtensionFileNotSupported): + return f"File has not a valid extensions, supported extensions are: {self.extensions}" + return None + + +# Validator for folder paths +class Folder(Validator): + def __init__( + self, + must_exists: bool = False, + must_not_exists: bool = False, + required_permissions: str | None = None, + failure_description: str | None = None, + ) -> None: + """ + Initialize the folder validator. + + :param must_exists: Specify if the folder must exist. + :param must_not_exists: Specify if the folder must not exist. + :param required_permission: Folder permission to check (e.g., "r", "w", "x"). + """ + super().__init__(failure_description=failure_description) + self.must_exists = must_exists + self.must_not_exists = must_not_exists + if self.must_exists and self.must_not_exists: + raise ValueError( + "Conflicts on existence contraints, a path must exists or not" + ) from None + self.required_permissions = required_permissions or "" + + class NotAFolder(Failure): + """Indicate that the path is not a folder.""" + + class DoesNotExists(Failure): + """Indicate that the file does not exists.""" + + class AlreadyExists(Failure): + """Indicate that the file exists and should not.""" + + class IsNotReadable(Failure): + """Indicate that the file is not readable.""" + + class IsNotWritable(Failure): + """Indicate that the file is not writable.""" + + class IsNotExecutable(Failure): + """Indicate that the file is not executable.""" + + def validate(self, value: str) -> ValidationResult: + """Check if the given path meets all file requirements.""" + folder_path = Path(value) + + # Check if path is a folder + if folder_path.exists() and not folder_path.is_dir(): + return ValidationResult.failure([Folder.NotAFolder(self, value)]) + + # Check existence + if self.must_exists and not folder_path.exists(): + return ValidationResult.failure([Folder.DoesNotExists(self, value)]) + if self.must_not_exists and folder_path.exists(): + return ValidationResult.failure([Folder.AlreadyExists(self, value)]) + + # Check file permissions + if folder_path.exists(): + if "r" in self.required_permissions: + if not os.access(folder_path, mode=os.R_OK): + return ValidationResult.failure([Folder.IsNotReadable(self, value)]) + if "w" in self.required_permissions: + if not os.access(folder_path, mode=os.W_OK): + return ValidationResult.failure([Folder.IsNotWritable(self, value)]) + if "x" in self.required_permissions: + if not os.access(folder_path, mode=os.X_OK): + return ValidationResult.failure( + [Folder.IsNotExecutable(self, value)] + ) + + return self.success() + + def describe_failure(self, failure: Failure) -> str | None: + """Describes why the validator failed. + + Args: + failure: Information about why the validation failed. + + Returns: + A string description of the failure. + """ + if isinstance(failure, Folder.NotAFolder): + return "Path is not a folder" + elif isinstance(failure, Folder.DoesNotExists): + return "Folder does not exist" + elif isinstance(failure, Folder.AlreadyExists): + return "Folder does already exist" + elif isinstance(failure, Folder.IsNotReadable): + return "Folder is not readable by this user" + elif isinstance(failure, Folder.IsNotWritable): + return "Folder is not writable by this user" + elif isinstance(failure, Folder.IsNotExecutable): + return "Folder is not executable by this user" + return None diff --git a/tests/test_validation.py b/tests/test_validation.py index 38be8b38e9..7f63e956fe 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,10 +1,13 @@ from __future__ import annotations + import pytest from textual.validation import ( URL, Failure, + File, + Folder, Function, Integer, Length, @@ -160,7 +163,12 @@ def test_Regex_validate(regex, value, expected_result): ("123", 100, 200, True), # valid integer within range ("99", 100, 200, False), # valid integer but not in range ("201", 100, 200, False), # valid integer but not in range - ("1.23e4", None, None, False), # valid scientific notation, even resolving to an integer, is not valid + ( + "1.23e4", + None, + None, + False, + ), # valid scientific notation, even resolving to an integer, is not valid ("123.", None, None, False), # periods not valid in integers ("123_456", None, None, True), # underscores are valid python ("_123_456", None, None, False), # leading underscores are not valid python @@ -232,3 +240,150 @@ def test_Integer_failure_description_when_NotANumber(): result = validator.validate("x") assert result.is_valid is False assert result.failure_descriptions[0] == "Must be a valid integer." + + +# Test File validator + + +def test_File_existing_file_with_correct_extension(tmp_path): + # create dir + tmp_path.mkdir(parents=True, exist_ok=True) + temp_file = tmp_path / "correct_extension.txt" + # create file + temp_file.touch() + # instantiate File validator + validator = File(must_exists=True, extensions=".txt") + result = validator.validate(str(temp_file)) + assert result.is_valid + + +def test_File_existing_file_with_wrong_extension(tmp_path): + # create dir + tmp_path.mkdir(parents=True, exist_ok=True) + temp_file = tmp_path / "wrong_extension.txt" + # create file + temp_file.touch() + # instantiate File validator + validator = File(must_exists=True, extensions=".md") + result = validator.validate(str(temp_file)) + assert not result.is_valid + assert ( + result.failure_descriptions[0] + == "File has not a valid extensions, supported extensions are: ['.md']" + ) + + +def test_File_non_existing_file_when_must_exist(tmp_path): + # create dir + temp_file = tmp_path / "nonexisting_file.txt" + # instantiate File validator + validator = File(must_exists=True) + result = validator.validate(str(temp_file)) + assert not result.is_valid + assert result.failure_descriptions[0] == "File does not exist" + + +def test_File_non_existing_file_when_must_not_exist(tmp_path): + temp_file = tmp_path / "nonexisting_file.txt" + # instantiate File validator + validator = File(must_exists=False) + result = validator.validate(str(temp_file)) + assert result.is_valid + + +def test_File_existing_file_without_permission(tmp_path): + # create dir + tmp_path.mkdir(parents=True, exist_ok=True) + temp_file = tmp_path / "wrong_permission.txt" + # create file + temp_file.touch() + # instantiate File validator + # Simulate no read permission + temp_file.chmod(0o200) + validator = File(must_exists=True, required_permissions="r") + result = validator.validate(str(temp_file)) + assert not result.is_valid + assert result.failure_descriptions[0] == "File is not readable by this user" + + +def test_File_existing_file_with_required_permission(tmp_path): + # create dir + tmp_path.mkdir(parents=True, exist_ok=True) + temp_file = tmp_path / "correct_permission.txt" + # create file + temp_file.touch() + # instantiate File validator + validator = File(must_exists=True, required_permissions="r") + result = validator.validate(str(temp_file)) + assert result.is_valid + + +def test_File_conflict_existence_contraints(tmp_path): + # instantiate File validator + with pytest.raises(ValueError): + File(must_exists=True, must_not_exists=True, extensions=".txt") + + +# Test Folder validator + + +def test_Folder_existing_folder_with_permissions(tmp_path): + # create dir + tmp_path.mkdir(parents=True, exist_ok=True) + # instantiate File validator + validator = Folder(must_exists=True, required_permissions="rwx") + result = validator.validate(str(tmp_path)) + assert result.is_valid + + +def test_Folder_non_existing_folder_when_must_exist(tmp_path): + temp_folder = tmp_path / "nonexisting_folder" + validator = Folder(must_exists=True) + result = validator.validate(str(temp_folder)) + assert not result.is_valid + assert result.failure_descriptions[0] == "Folder does not exist" + + +def test_Folder_non_existing_folder_when_must_not_exist(tmp_path): + temp_folder = tmp_path / "nonexisting_folder" + validator = Folder(must_exists=False) + result = validator.validate(temp_folder) + assert result.is_valid + + +def test_Folder_existing_folder_without_write_permission(tmp_path): + temp_folder = tmp_path / "nowrite_permission" + temp_folder.mkdir(parents=True, exist_ok=True) + # Remove write permission + temp_folder.chmod(0o500) + validator = Folder(must_exists=True, required_permissions="w") + result = validator.validate(str(temp_folder)) + assert not result.is_valid + assert result.failure_descriptions[0] == "Folder is not writable by this user" + + +def test_Folder_existing_folder_without_execute_permission(tmp_path): + temp_folder = tmp_path / "noexec_permission" + temp_folder.mkdir(parents=True, exist_ok=True) + # Remove execute permission + temp_folder.chmod(0o600) + validator = Folder(must_exists=True, required_permissions="x") + result = validator.validate(str(temp_folder)) + assert not result.is_valid + assert result.failure_descriptions[0] == "Folder is not executable by this user" + + +def test_Folder_is_file(tmp_path): + tmp_path.mkdir(parents=True, exist_ok=True) + temp_file = tmp_path / "a_file.txt" + temp_file.touch() + validator = Folder(must_exists=True) + result = validator.validate(str(temp_file)) + assert not result.is_valid + assert result.failure_descriptions[0] == "Path is not a folder" + + +def test_Folder_conflict_existence_contraints(tmp_path): + # instantiate File validator + with pytest.raises(ValueError): + Folder(must_exists=True, must_not_exists=True)