Skip to content

Commit 25a41b3

Browse files
author
Pietro Zambelli
committed
feat(validator): Add File and Folder validator
Custom Validator to check file and folder requirements fix Textualize#5203
1 parent 2413818 commit 25a41b3

File tree

2 files changed

+364
-2
lines changed

2 files changed

+364
-2
lines changed

src/textual/validation.py

+208-1
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010

1111
import math
1212
import re
13+
import os
1314
from abc import ABC, abstractmethod
1415
from dataclasses import dataclass, field
16+
from pathlib import Path
1517
from typing import Callable, Pattern, Sequence
1618
from urllib.parse import urlparse
1719

@@ -355,7 +357,7 @@ def validate(self, value: str) -> ValidationResult:
355357

356358
# We know it's a number, but is that number an integer?
357359
try:
358-
int_value = int(value)
360+
int(value)
359361
except ValueError:
360362
return ValidationResult.failure([Integer.NotAnInteger(self, value)])
361363
return self.success()
@@ -511,3 +513,208 @@ def describe_failure(self, failure: Failure) -> str | None:
511513
A string description of the failure.
512514
"""
513515
return "Must be a valid URL."
516+
517+
518+
class File(Validator):
519+
def __init__(
520+
self,
521+
must_exists: bool = False,
522+
must_not_exists: bool = False,
523+
required_permissions: str | None = None,
524+
extensions: str | list[str] | None = None,
525+
failure_description: str | None = None,
526+
) -> None:
527+
"""
528+
Initialize the file validator.
529+
530+
:param must_exist: Specify if the file must exist.
531+
:param must__not_exist: Specify if the file must not exist.
532+
:param required_permissions: File permission to check (e.g., "r", "w", "x", "rwx").
533+
:param extensions: Expected file extension (e.g., ".txt").
534+
"""
535+
super().__init__(failure_description=failure_description)
536+
self.must_exists = must_exists
537+
self.must_not_exists = must_not_exists
538+
if self.must_exists and self.must_not_exists:
539+
raise ValueError(
540+
"Conflicts on existence contraints, a path must exists or not"
541+
) from None
542+
self.required_permissions = required_permissions or ""
543+
self.extensions = [extensions] if isinstance(extensions, str) else extensions
544+
545+
class NotAFile(Failure):
546+
"""Indicate that the path is not a file."""
547+
548+
class DoesNotExists(Failure):
549+
"""Indicate that the file does not exists."""
550+
551+
class AlreadyExists(Failure):
552+
"""Indicate that the file exists and should not."""
553+
554+
class ExtensionFileNotSupported(Failure):
555+
"""Indicate that the file exists and should not."""
556+
557+
class IsNotReadable(Failure):
558+
"""Indicate that the file is not readable."""
559+
560+
class IsNotWritable(Failure):
561+
"""Indicate that the file is not writable."""
562+
563+
class IsNotExecutable(Failure):
564+
"""Indicate that the file is not executable."""
565+
566+
def validate(self, value: str) -> ValidationResult:
567+
"""Check if the given path meets all file requirements."""
568+
file_path = Path(value)
569+
570+
# Check if path is a file
571+
if file_path.exists() and not file_path.is_file():
572+
return ValidationResult.failure([File.NotAFile(self, value)])
573+
574+
# Check existence
575+
if self.must_exists and not file_path.exists():
576+
return ValidationResult.failure([File.DoesNotExists(self, value)])
577+
if self.must_not_exists and file_path.exists():
578+
return ValidationResult.failure([File.AlreadyExists(self, value)])
579+
580+
# Check file permissions
581+
if file_path.exists():
582+
if "r" in self.required_permissions:
583+
if not os.access(file_path, mode=os.R_OK):
584+
return ValidationResult.failure([File.IsNotReadable(self, value)])
585+
if "w" in self.required_permissions:
586+
if not os.access(file_path, mode=os.W_OK):
587+
return ValidationResult.failure([File.IsNotWritable(self, value)])
588+
if "x" in self.required_permissions:
589+
if not os.access(file_path, mode=os.X_OK):
590+
return ValidationResult.failure([File.IsNotExecutable(self, value)])
591+
592+
# Check file extension
593+
if self.extensions and file_path.suffix not in self.extensions:
594+
return ValidationResult.failure(
595+
[File.ExtensionFileNotSupported(self, value)]
596+
)
597+
598+
return self.success()
599+
600+
def describe_failure(self, failure: Failure) -> str | None:
601+
"""Describes why the validator failed.
602+
603+
Args:
604+
failure: Information about why the validation failed.
605+
606+
Returns:
607+
A string description of the failure.
608+
"""
609+
if isinstance(failure, File.NotAFile):
610+
return "Path is not a file"
611+
elif isinstance(failure, File.DoesNotExists):
612+
return "File does not exist"
613+
elif isinstance(failure, File.AlreadyExists):
614+
return "File does already exist"
615+
elif isinstance(failure, File.IsNotReadable):
616+
return "File is not readable by this user"
617+
elif isinstance(failure, File.IsNotWritable):
618+
return "File is not writable by this user"
619+
elif isinstance(failure, File.IsNotExecutable):
620+
return "File is not executable by this user"
621+
elif isinstance(failure, File.ExtensionFileNotSupported):
622+
return f"File has not a valid extensions, supported extensions are: {self.extensions}"
623+
return None
624+
625+
626+
# Validator for folder paths
627+
class Folder(Validator):
628+
def __init__(
629+
self,
630+
must_exists: bool = False,
631+
must_not_exists: bool = False,
632+
required_permissions: str | None = None,
633+
failure_description: str | None = None,
634+
) -> None:
635+
"""
636+
Initialize the folder validator.
637+
638+
:param must_exists: Specify if the folder must exist.
639+
:param must_not_exists: Specify if the folder must not exist.
640+
:param required_permission: Folder permission to check (e.g., "r", "w", "x").
641+
"""
642+
super().__init__(failure_description=failure_description)
643+
self.must_exists = must_exists
644+
self.must_not_exists = must_not_exists
645+
if self.must_exists and self.must_not_exists:
646+
raise ValueError(
647+
"Conflicts on existence contraints, a path must exists or not"
648+
) from None
649+
self.required_permissions = required_permissions or ""
650+
651+
class NotAFolder(Failure):
652+
"""Indicate that the path is not a folder."""
653+
654+
class DoesNotExists(Failure):
655+
"""Indicate that the file does not exists."""
656+
657+
class AlreadyExists(Failure):
658+
"""Indicate that the file exists and should not."""
659+
660+
class IsNotReadable(Failure):
661+
"""Indicate that the file is not readable."""
662+
663+
class IsNotWritable(Failure):
664+
"""Indicate that the file is not writable."""
665+
666+
class IsNotExecutable(Failure):
667+
"""Indicate that the file is not executable."""
668+
669+
def validate(self, value: str) -> ValidationResult:
670+
"""Check if the given path meets all file requirements."""
671+
folder_path = Path(value)
672+
673+
# Check if path is a folder
674+
if folder_path.exists() and not folder_path.is_dir():
675+
return ValidationResult.failure([Folder.NotAFolder(self, value)])
676+
677+
# Check existence
678+
if self.must_exists and not folder_path.exists():
679+
return ValidationResult.failure([Folder.DoesNotExists(self, value)])
680+
if self.must_not_exists and folder_path.exists():
681+
return ValidationResult.failure([Folder.AlreadyExists(self, value)])
682+
683+
# Check file permissions
684+
if folder_path.exists():
685+
if "r" in self.required_permissions:
686+
if not os.access(folder_path, mode=os.R_OK):
687+
return ValidationResult.failure([Folder.IsNotReadable(self, value)])
688+
if "w" in self.required_permissions:
689+
if not os.access(folder_path, mode=os.W_OK):
690+
return ValidationResult.failure([Folder.IsNotWritable(self, value)])
691+
if "x" in self.required_permissions:
692+
if not os.access(folder_path, mode=os.X_OK):
693+
return ValidationResult.failure(
694+
[Folder.IsNotExecutable(self, value)]
695+
)
696+
697+
return self.success()
698+
699+
def describe_failure(self, failure: Failure) -> str | None:
700+
"""Describes why the validator failed.
701+
702+
Args:
703+
failure: Information about why the validation failed.
704+
705+
Returns:
706+
A string description of the failure.
707+
"""
708+
if isinstance(failure, Folder.NotAFolder):
709+
return "Path is not a folder"
710+
elif isinstance(failure, Folder.DoesNotExists):
711+
return "Folder does not exist"
712+
elif isinstance(failure, Folder.AlreadyExists):
713+
return "Folder does already exist"
714+
elif isinstance(failure, Folder.IsNotReadable):
715+
return "Folder is not readable by this user"
716+
elif isinstance(failure, Folder.IsNotWritable):
717+
return "Folder is not writable by this user"
718+
elif isinstance(failure, Folder.IsNotExecutable):
719+
return "Folder is not executable by this user"
720+
return None

0 commit comments

Comments
 (0)