Browse Source

review and add tests for 10 methods

olinox14 1 year ago
parent
commit
5e1c71b8b3
6 changed files with 355 additions and 166 deletions
  1. 2 1
      composer.json
  2. 4 2
      composer.lock
  3. 16 0
      src/Exception/IOException.php
  4. 102 161
      src/Path.php
  5. 230 2
      tests/PathTest.php
  6. 1 0
      tests/tempmy_file.bin

+ 2 - 1
composer.json

@@ -20,7 +20,8 @@
     }
   },
   "require-dev": {
-    "phpunit/phpunit": "^9.6"
+    "phpunit/phpunit": "^9.6",
+    "ext-posix": "*"
   },
   "minimum-stability": "dev",
   "prefer-stable": true

+ 4 - 2
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "bad12670d217253607f5e5b7c174b784",
+    "content-hash": "c6efec0c1994f1186300f984fb5792a2",
     "packages": [],
     "packages-dev": [
         {
@@ -1748,6 +1748,8 @@
     "prefer-stable": true,
     "prefer-lowest": false,
     "platform": [],
-    "platform-dev": [],
+    "platform-dev": {
+        "ext-posix": "*"
+    },
     "plugin-api-version": "2.6.0"
 }

+ 16 - 0
src/Exception/IOException.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace Path\Exception;
+
+class IOException extends \Exception
+{
+    public function __construct($message = "Read/write error", $code = 0, \Exception $previous = null)
+    {
+        parent::__construct($message, $code, $previous);
+    }
+
+    public function __toString()
+    {
+        return __CLASS__ . ": [{$this->code}]: {$this->message}\n";
+    }
+}

+ 102 - 161
src/Path.php

@@ -5,6 +5,9 @@ namespace Path;
 use Generator;
 use Path\Exception\FileExistsException;
 use Path\Exception\FileNotFoundException;
+use Path\Exception\IOException;
+use RuntimeException;
+use Throwable;
 
 /**
  * Represents a file or directory path.
@@ -32,6 +35,8 @@ class Path
 
     protected string $path;
 
+    protected mixed $handle;
+
     /**
      * Joins two or more parts of a path together, inserting '/' as needed.
      * If any component is an absolute path, all previous path components
@@ -159,6 +164,7 @@ class Path
     public function __construct(string $path)
     {
         $this->path = $path;
+        $this->handle = null;
         return $this;
     }
 
@@ -231,7 +237,7 @@ class Path
             self::R_OK => is_readable($this->path),
             self::W_OK => is_writable($this->path),
             self::X_OK => is_executable($this->path),
-            default => throw new \RuntimeException('Invalid mode'),
+            default => throw new RuntimeException('Invalid mode'),
         };
     }
 
@@ -445,15 +451,18 @@ class Path
      * Retrieves the content of a file.
      *
      * @return bool|string The content of the file as a string.
-     * @throws FileNotFoundException
+     * @throws FileNotFoundException|IOException
      */
     public function getContent(): bool|string
     {
         if (!$this->isFile()) {
             throw new FileNotFoundException("File does not exist : " . $this->path);
         }
-        // TODO: review use-cases
-        return file_get_contents($this->path);
+        $text = file_get_contents($this->path);
+        if ($text === false) {
+            throw new IOException("Error reading file {$this->path}");
+        }
+        return $text;
     }
 
     /**
@@ -517,6 +526,25 @@ class Path
         return chmod($this->path, $permissions);
     }
 
+    /**
+     * Changes ownership of the file.
+     *
+     * @param string $user The new owner username.
+     * @param string $group The new owner group name.
+     * @return bool
+     * @throws FileNotFoundException
+     */
+    public function setOwner(string $user, string $group): bool
+    {
+        if (!$this->isFile()) {
+            throw new FileNotFoundException("File or dir does not exist : " . $this->path);
+        }
+        clearstatcache(); // TODO: check for a better way of dealing with PHP cache
+        return
+            chown($this->path, $user) &&
+            chgrp($this->path, $group);
+    }
+
     /**
      * Checks if a file exists.
      *
@@ -558,38 +586,43 @@ class Path
         }
     }
 
-    public function open(string $mode = 'r')
+    /**
+     * Opens a file in the specified mode.
+     *
+     * @param string $mode The mode in which to open the file. Defaults to 'r'.
+     * @return resource|false Returns a file pointer resource on success, or false on failure.
+     * @throws FileNotFoundException If the path does not refer to a file.
+     * @throws IOException If the file fails to open.
+     */
+    public function open(string $mode = 'r'): mixed
     {
         if (!$this->isFile()) {
-            throw new \RuntimeException("{$this->path} is not a file");
+            throw new FileNotFoundException("{$this->path} is not a file");
         }
 
         $handle = fopen($this->path, $mode);
         if ($handle === false) {
-            throw new \RuntimeException("Failed opening file {$this->path}");
+            throw new IOException("Failed opening file {$this->path}");
         }
 
         return $handle;
     }
 
     /**
-     * Returns this path as a URI.
-     *
-     * @return string
-     */
-    public function as_uri(): string
-    {
-        throw new \Exception("Method not implemented");
-    }
-
-    /**
-     * Returns the group that owns the file.
+     * Calls a callback with a file handle opened with the specified mode and closes the handle afterward.
      *
-     * @return string
+     * @param callable $callback The callback function to be called with the file handle.
+     * @param string $mode The mode in which to open the file. Defaults to 'r'.
+     * @throws Throwable If an exception is thrown within the callback function.
      */
-    public function group(): string
+    public function with(callable $callback, string $mode = 'r'): void
     {
-        throw new \Exception("Method not implemented");
+        $handle = $this->open($mode);
+        try {
+            $callback($handle);
+        } finally {
+            fclose($handle);
+        }
     }
 
     /**
@@ -597,34 +630,26 @@ class Path
      *
      * @return bool
      */
-    public function is_absolute(): bool
+    public function isAbs(): bool
     {
-        return substr($this->path, 0, 1) === '/';
-    }
-
-    /**
-     * (Not supported in PHP). In Python, this would convert the path to POSIX style, but in PHP there's no equivalent.
-     * Therefore throwing an exception.
-     *
-     * @throws \RuntimeException
-     */
-    public function as_posix(): void
-    {
-        throw new \RuntimeException("Method 'as_posix' not supported in PHP");
+        return str_starts_with($this->path, '/');
     }
 
     /**
+     * > Alias for Path->setPermissions() method
      * Changes permissions of the file.
      *
      * @param int $mode The new permissions (octal).
      * @return bool
+     * @throws FileNotFoundException
      */
     public function chmod(int $mode): bool
     {
-        return chmod($this->path, $mode);
+        return $this->setPermissions($mode);
     }
 
     /**
+     * > Alias for Path->setOwner() method
      * Changes ownership of the file.
      *
      * @param string $user The new owner username.
@@ -633,47 +658,7 @@ class Path
      */
     public function chown(string $user, string $group): bool
     {
-        return chown($this->path, $user) && chgrp($this->path, $group);
-    }
-
-    /**
-     * Checks if file is a block special file.
-     *
-     * @return bool
-     */
-    public function is_block_device(): bool
-    {
-        return function_exists('posix_isatty') && is_file($this->path) && posix_isatty($this->path);
-    }
-
-    /**
-     * Checks if file is a character special file.
-     *
-     * @return bool
-     */
-    public function is_char_device(): bool
-    {
-        return function_exists('filetype') && filetype($this->path) === 'char';
-    }
-
-    /**
-     * Checks if file is a Named Pipe (FIFO) special file.
-     *
-     * @return bool
-     */
-    public function is_fifo(): bool
-    {
-        return function_exists('filetype') && filetype($this->path) === 'fifo';
-    }
-
-    /**
-     * Checks if file is a socket.
-     *
-     * @return bool
-     */
-    public function is_socket(): bool
-    {
-        return function_exists('filetype') && 'socket' === filetype($this->path);
+        return $this->setOwner($user, $group);
     }
 
     /**
@@ -681,7 +666,7 @@ class Path
      *
      * @return bool
      */
-    public function is_symlink(): bool
+    public function isLink(): bool
     {
         return is_link($this->path);
     }
@@ -689,62 +674,33 @@ class Path
     /**
      * Iterate over the files in this directory.
      *
-     * @return \Generator
-     * @throws \RuntimeException if the path is not a directory.
+     * @return Generator
+     * @throws FileNotFoundException if the path is not a directory.
      */
-    public function iterdir()
+    public function iterDir(): Generator
     {
         if (!$this->isDir()) {
-            throw new \RuntimeException("{$this->path} is not a directory");
+            throw new FileNotFoundException("{$this->path} is not a directory");
         }
 
         foreach (new \DirectoryIterator($this->path) as $fileInfo) {
-            if ($fileInfo->isDot()) continue;
+            // TODO: use the DirectoryIterator everywhere else?
+            if ($fileInfo->isDot()) {
+                continue;
+            }
             yield $fileInfo->getFilename();
         }
     }
 
-
-
-    /**
-     * Change the mode of path to the numeric mode.
-     * This method does not follow symbolic links.
-     *
-     * @param int $mode
-     * @return bool
-     */
-    public function lchmod(int $mode): bool
-    {
-        if (!function_exists('lchmod')) {
-            return false;
-        }
-        return lchmod($this->path, $mode);
-    }
-
-    /**
-     * Change the owner and group id of path to the numeric uid and gid.
-     * This method does not follow symbolic links.
-     *
-     * @param int $uid User id
-     * @param int $gid Group id
-     * @return bool
-     */
-    public function lchown(int $uid, int $gid): bool
-    {
-        if (!function_exists('lchown')) {
-            return false;
-        }
-        return lchown($this->path, $uid) && lchgrp($this->path, $gid);
-    }
-
     /**
      * Create a hard link pointing to a path.
      *
-     * @param string $target
+     * @param string|Path $target
      * @return bool
      */
-    public function link_to(string $target): bool
+    public function link(string|self $target): bool
     {
+        $target = (string)$target;
         if (!function_exists('link')) {
             return false;
         }
@@ -756,76 +712,61 @@ class Path
      *
      * @return array|false
      */
-    public function lstat()
+    public function lstat(): bool|array
     {
         return lstat($this->path);
     }
 
     /**
      * Returns the individual parts of this path.
+     * The eventual leading directory separator is kept.
+     *
+     * Ex:
+     *
+     *     Path('/foo/bar/baz').parts()
+     *     >>> '/', 'foo', 'bar', 'baz'
      *
      * @return array
      */
     public function parts(): array
     {
-        $separator = DIRECTORY_SEPARATOR;
-        return explode($separator, $this->path);
-    }
-
-    /**
-     * Opens the file in bytes mode, reads it, and closes the file.
-     *
-     * @return string
-     * @throws \RuntimeException
-     */
-    public function read_bytes(): string
-    {
-        $bytes = file_get_contents($this->path, FILE_BINARY);
-        if ($bytes === false) {
-            throw new \RuntimeException("Error reading file {$this->path}");
+        $parts = [];
+        if (str_starts_with($this->path, DIRECTORY_SEPARATOR)) {
+            $parts[] = DIRECTORY_SEPARATOR;
         }
-
-        return $bytes;
+        $parts += explode(DIRECTORY_SEPARATOR, $this->path);
+        return $parts;
     }
 
     /**
-     * Open the file in text mode, read it, and close the file
+     * Compute a version of this path that is relative to another path.
      *
+     * @param string|Path $basePath
      * @return string
-     * @throws \RuntimeException
+     * @throws FileNotFoundException
      */
-    public function read_text(): string
+    public function getRelativePath(string|self $basePath): string
     {
-        $text = file_get_contents($this->path);
-        if ($text === false) {
-            throw new \RuntimeException("Error reading file {$this->path}");
+        if (!$this->exists()) {
+            throw new FileNotFoundException("{$this->path} is not a file or directory");
         }
 
-        return $text;
-    }
+        $path = $this->abspath();
+        $basePath = (string)$basePath;
 
-    /**
-     * Compute a version of this path that is relative to another path.
-     *
-     * @return string
-     * @throws \RuntimeException
-     */
-    public function relative_to(string $other_path): string
-    {
-        $path = $this->absolute();
-        $other = realpath($other_path);
-        if ($other === false) {
-            throw new \RuntimeException("$other_path does not exist or unable to get a real path");
+        $realBasePath = realpath($basePath);
+        if ($realBasePath === false) {
+            throw new FileNotFoundException("$basePath does not exist or unable to get a real path");
         }
 
-        $path_parts = explode(DIRECTORY_SEPARATOR, $path);
-        $other_parts = explode(DIRECTORY_SEPARATOR, $other);
+        $pathParts = explode(DIRECTORY_SEPARATOR, $path);
+        $baseParts = explode(DIRECTORY_SEPARATOR, $realBasePath);
 
-        while (count($path_parts) && count($other_parts) && ($path_parts[0] == $other_parts[0])) {
-            array_shift($path_parts);
-            array_shift($other_parts);
+        while (count($pathParts) && count($baseParts) && ($pathParts[0] == $baseParts[0])) {
+            array_shift($pathParts);
+            array_shift($baseParts);
         }
 
-        return str_repeat('..' . DIRECTORY_SEPARATOR, count($other_parts)) . implode(DIRECTORY_SEPARATOR, $path_parts);
+        return str_repeat('..' . DIRECTORY_SEPARATOR, count($baseParts)) . implode(DIRECTORY_SEPARATOR, $pathParts);
     }
 }

+ 230 - 2
tests/PathTest.php

@@ -4,6 +4,7 @@ namespace Path\Tests;
 
 use Path\Exception\FileExistsException;
 use Path\Exception\FileNotFoundException;
+use Path\Exception\IOException;
 use Path\Path;
 use PHPUnit\Framework\TestCase;
 
@@ -17,7 +18,8 @@ class PathTest extends TestCase
 
     public function setUp(): void
     {
-        mkdir(self::TEMP_TEST_DIR);
+        clearstatcache();
+        mkdir(self::TEMP_TEST_DIR, 0777, true);
         chdir(self::TEMP_TEST_DIR);
     }
 
@@ -1086,7 +1088,6 @@ class PathTest extends TestCase
     public function testSetPermissionsFileNotExists(): void
     {
         $src = self::TEMP_TEST_DIR . "/foo.txt";
-
         $path = new Path($src);
 
         $this->expectException(FileNotFoundException::class);
@@ -1231,4 +1232,231 @@ class PathTest extends TestCase
             is_dir($src)
         );
     }
+
+    /**
+     * @throws IOException
+     * @throws FileNotFoundException
+     */
+    public function testOpen(): void {
+        $src = self::TEMP_TEST_DIR . "/foo.txt";
+        touch($src);
+
+        $path = new Path($src);
+
+        $handle = $path->open();
+
+        $this->assertIsResource($handle);
+    }
+
+    /**
+     * @throws IOException
+     * @throws FileNotFoundException
+     */
+    public function testOpenNotExistingFile(): void {
+        $src = self::TEMP_TEST_DIR . "/foo.txt";
+
+        $path = new Path($src);
+
+        $this->expectException(FileNotFoundException::class);
+        $this->expectExceptionMessage(self::TEMP_TEST_DIR . "/foo.txt is not a file");
+
+        $path->open();
+    }
+
+    /**
+     * @throws IOException
+     * @throws FileNotFoundException
+     */
+    public function testOpenIsDir(): void {
+        $src = self::TEMP_TEST_DIR . "/foo.txt";
+        $path = new Path($src);
+
+        $this->expectException(FileNotFoundException::class);
+        $this->expectExceptionMessage(self::TEMP_TEST_DIR . "/foo.txt is not a file");
+
+        $path->open();
+    }
+
+    public function testIsAbs() {
+        $path1 = new Path('/absolute/path');
+        $this->assertTrue($path1->isAbs());
+
+        $path2 = new Path('relative/path');
+        $this->assertFalse($path2->isAbs());
+    }
+
+    /**
+     * @throws FileNotFoundException
+     */
+    public function testChmod(): void
+    {
+        $src = self::TEMP_TEST_DIR . "/foo.txt";
+        $path = $this
+            ->getMockBuilder(Path::class)
+            ->onlyMethods(['setPermissions'])
+            ->setConstructorArgs([$src])
+            ->getMock();
+
+        $path->expects(self::once())->method('setPermissions')->with(0770);
+
+        $path->chmod(0770);
+    }
+
+    /**
+     * @throws FileNotFoundException
+     */
+    public function testChown(): void
+    {
+        $src = self::TEMP_TEST_DIR . "/foo.txt";
+        $path = $this
+            ->getMockBuilder(Path::class)
+            ->onlyMethods(['setOwner'])
+            ->setConstructorArgs([$src])
+            ->getMock();
+
+        $path->expects(self::once())->method('setOwner')->with(2, 2);
+
+        $path->chown(2, 2);
+    }
+
+    public function testIsLink(): void
+    {
+        $src = self::TEMP_TEST_DIR . "/foo.txt";
+        touch($src);
+        $path1 = new Path($src);
+
+        $target = self::TEMP_TEST_DIR . "/link.txt";
+        symlink($src, $target);
+        $path2 = new Path($target);
+
+        $this->assertFalse($path1->isLink());
+        $this->assertTrue($path2->isLink());
+    }
+
+    /**
+     * @throws FileNotFoundException
+     */
+    public function testIterDir(): void {
+        $dir = self::TEMP_TEST_DIR . "/foo";
+        mkdir($dir);
+
+        $file1 = $dir . "/file1.ext";
+        touch($file1);
+
+        $file2 = $dir . "/file2.ext";
+        touch($file2);
+
+        $subDir = $dir . "/sub_dir";
+        mkdir($subDir);
+
+        $path = new Path($dir);
+
+        // TODO: test the generator behavior without array conversion
+        $this->assertEquals(
+            ['file1.ext', 'file2.ext', 'sub_dir'],
+            iterator_to_array($path->iterDir())
+        );
+    }
+
+    /**
+     * @throws FileNotFoundException
+     */
+    public function testIterDirNotExisting(): void {
+        $dir = self::TEMP_TEST_DIR . "/foo";
+        $path = new Path($dir);
+
+        $this->expectException(FileNotFoundException::class);
+        $this->expectExceptionMessage($dir . " is not a directory");
+
+        iterator_to_array($path->iterDir());
+    }
+
+    /**
+     * @throws FileNotFoundException
+     */
+    public function testIterDirIsFile(): void {
+        $src = self::TEMP_TEST_DIR . "/foo";
+        touch($src);
+        $path = new Path($src);
+
+        $this->expectException(FileNotFoundException::class);
+        $this->expectExceptionMessage($src . " is not a directory");
+
+        iterator_to_array($path->iterDir());
+    }
+
+    public function testLink(): void
+    {
+        $src = self::TEMP_TEST_DIR . "/foo.txt";
+        touch($src);
+
+        $dst = self::TEMP_TEST_DIR . "/link.txt";
+
+        $path = new Path($src);
+
+        $path->link($dst);
+        $this->assertTrue(file_exists($dst));
+    }
+
+    public function testLinkWithPath(): void
+    {
+        $src = self::TEMP_TEST_DIR . "/foo.txt";
+        touch($src);
+
+        $dst = self::TEMP_TEST_DIR . "/link.txt";
+
+        $path = new Path($src);
+
+        $path->link(new Path($dst));
+        $this->assertTrue(file_exists($dst));
+    }
+
+    public function testLstat(): void
+    {
+        $src = self::TEMP_TEST_DIR . "/foo.txt";
+        file_put_contents($src,'foo');
+
+        $path = new Path($src);
+
+        self::assertSame(
+            lstat($src),
+            $path->lstat()
+        );
+    }
+
+    public function testParts(): void
+    {
+        $path = new Path("foo/bar/my_file.txt");
+
+        self::assertEquals(
+            ['foo', 'bar', 'my_file.txt'],
+            $path->parts()
+        );
+    }
+
+    public function testPartsWithLeadingSeparator(): void
+    {
+        $path = new Path("/foo/bar/my_file.txt");
+
+        self::assertEquals(
+            ['/', 'foo', 'bar', 'my_file.txt'],
+            $path->parts()
+        );
+    }
+
+    /**
+     * @throws FileNotFoundException
+     */
+    public function testGetRelativePath()
+    {
+        $src = self::TEMP_TEST_DIR . "/foo";
+        touch($src);
+
+        $path = new Path($src);
+
+        $this->assertSame(
+            "foo",
+            $path->getRelativePath(self::TEMP_TEST_DIR)
+        );
+    }
 }

+ 1 - 0
tests/tempmy_file.bin

@@ -0,0 +1 @@
+ba0c1874e2001b9c613c07dfad783b63247d8fc3a8a80b180c2071404b5f952c8600e0e70f9180d6ff03bb33bf8a34aef769