From ebd410d4e164da5f02c1e8a819935a9331e73a64 Mon Sep 17 00:00:00 2001 From: Pavel Stejskal Date: Fri, 17 Feb 2023 10:44:44 +0100 Subject: [PATCH] Init --- .gitattributes | 18 + .github/ISSUE_TEMPLATE/bug_report.md | 38 ++ .github/ISSUE_TEMPLATE/feature_request.md | 20 ++ .github/PULL_REQUEST_TEMPLATE.md | 43 +++ .github/dependabot.yml | 12 + .github/workflows/ci.yml | 54 +++ .gitignore | 11 + CHANGELOG.md | 8 + CONTRIBUTING.md | 33 ++ LICENSE | 21 ++ README.md | 60 ++++ composer.json | 66 ++++ phpcs.xml | 14 + phpstan.neon | 10 + phpunit.xml | 19 + src/File.php | 54 +++ src/FileInterface.php | 12 + src/Stream.php | 282 +++++++++++++++ src/StreamException.php | 11 + src/StreamInterface.php | 62 ++++ src/TempFile.php | 55 +++ src/TempStream.php | 28 ++ tests/FileTest.php | 27 ++ tests/StreamTest.php | 416 ++++++++++++++++++++++ tests/TempFileTest.php | 28 ++ tests/TempStreamTest.php | 30 ++ 26 files changed, 1432 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpcs.xml create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 src/File.php create mode 100644 src/FileInterface.php create mode 100644 src/Stream.php create mode 100644 src/StreamException.php create mode 100644 src/StreamInterface.php create mode 100644 src/TempFile.php create mode 100644 src/TempStream.php create mode 100644 tests/FileTest.php create mode 100644 tests/StreamTest.php create mode 100644 tests/TempFileTest.php create mode 100644 tests/TempStreamTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ee1174e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.editorconfig export-ignore +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/CHANGELOG.md export-ignore +/UPGRADING-1.0.md export-ignore +/CONTRIBUTING.md export-ignore +/LICENSE export-ignore +/phpcs.xml export-ignore +/phpunit.xml export-ignore +/phpstan.neon export-ignore +/tests export-ignore +/examples export-ignore +/docs export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1aa0d6f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the prob \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..24473de --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4f181d6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,43 @@ + + +## Description + +Describe your changes in detail. + +## Motivation and context + +Why is this change required? What problem does it solve? + +If it fixes an open issue, please link to the issue here (if you write `fixes #num` +or `closes #num`, the issue will be automatically closed when the pull is accepted.) + +## How has this been tested? + +Please describe in detail how you tested your changes. + +Include details of your testing environment, and the tests you ran to +see how your change affects other areas of the code, etc. + +## Screenshots (if appropriate) + +## Types of changes + +What types of changes does your code introduce? Put an `x` in all the boxes that apply: +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) + +## Checklist: + +Go over all the following points, and put an `x` in all the boxes that apply. + +Please, please, please, don't send your pull request until all of the boxes are ticked. Once your pull request is created, it will trigger a build on our [continuous integration](http://www.phptherightway.com/#continuous-integration) server to make sure your [tests and code style pass](https://help.github.com/articles/about-required-status-checks/). + +- [ ] I have read the **[CONTRIBUTING](CONTRIBUTING.md)** document. +- [ ] My pull request addresses exactly one patch/feature. +- [ ] I have created a branch for this patch/feature. +- [ ] Each individual commit in the pull request is meaningful. +- [ ] I have added tests to cover my changes. +- [ ] If my change requires a change to the documentation, I have updated it accordingly. + +If you're unsure about any of these, don't hesitate to ask. We're here to help! \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ae1bc63 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 + - package-ecosystem: composer + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..abb63de --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + - pull_request + - push + +jobs: + ci: + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + php: [8.1, 8.2] + dependency-version: [prefer-lowest, prefer-stable] + + name: PHP ${{ matrix.php }} (${{ matrix.dependency-version }}) + + steps: + + - name: Checkout + uses: actions/checkout@v3 + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ~/.composer/cache/files + key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, zip + tools: cs2pr + coverage: pcov + + - name: Composer Dependencies + run: composer update --${{ matrix.dependency-version }} --no-interaction --prefer-dist + + - name: CodeSniffer + run: vendor/bin/phpcs --report=checkstyle | cs2pr + + - name: PHPStan + run: vendor/bin/phpstan analyse --error-format=checkstyle | cs2pr + + - name: PHPUnit + run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml + + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Codecov + uses: codecov/codecov-action@v3.1.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b862633 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +###> IDE ### +/.idea + +# Composer +/vendor +/build + +# Build +/composer.lock +.phpunit.result.cache +.phpcs.cache diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..304056a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +All notable changes will be documented in this file. + +Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles. + +## [0.1.0] +First release 🚀 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2e8e6d7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +We accept contributions via Pull Requests on [Github](https://github.com/digitalcz/streams). + + +## Pull Requests + +- **[PSR-12 Coding Standard](https://www.php-fig.org/psr/psr-12/)** - Check the code style with ``$ composer cs`` and fix it with ``$ composer csfix``. + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. + +- **Create feature branches** - Don't ask us to pull from your master branch. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. + + +## Running Tests + +``` bash +$ composer csfix # fix codestyle +$ composer checks # run all checks +``` + + +**Happy coding**! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..013d816 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 DigitalCz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..67dde5f --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Streams + +[![Latest Stable Version](http://poser.pugx.org/digitalcz/streams/v)](https://packagist.org/packages/digitalcz/streams) +[![Total Downloads](http://poser.pugx.org/digitalcz/streams/downloads)](https://packagist.org/packages/digitalcz/streams) +[![Latest Unstable Version](http://poser.pugx.org/digitalcz/streams/v/unstable)](https://packagist.org/packages/digitalcz/streams) +[![License](http://poser.pugx.org/digitalcz/streams/license)](https://packagist.org/packages/digitalcz/streams) +[![PHP Version Require](http://poser.pugx.org/digitalcz/streams/require/php)](https://packagist.org/packages/digitalcz/streams) +[![CI](https://github.com/digitalcz/streams/workflows/CI/badge.svg)](https://github.com/digitalcz/streams/actions) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/digitalcz/streams/badges/quality-score.png?b=0.x)](https://scrutinizer-ci.com/g/digitalcz/streams/?branch=0.x) +[![codecov](https://codecov.io/gh/digitalcz/streams/branch/0.x/graph/badge.svg?token=QzZ5iMNkg3)](https://codecov.io/gh/digitalcz/streams) + +Opinionated abstraction around PHP streams loosely implementing PSR-7 StreamInterface + +## Install + +Via [Composer](https://getcomposer.org/) + +```bash +$ composer require digitalcz/streams +``` + +## Usage + +See [examples](examples) for more + +## Change log + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Testing + +``` bash +$ composer csfix # fix codestyle +$ composer checks # run all checks + +# or separately +$ composer tests # run phpunit +$ composer phpstan # run phpstan +$ composer cs # run codesniffer +``` + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## Security + +If you discover any security related issues, please email devs@digital.cz instead of using the issue tracker. + +## Credits + +- [Digital Solutions s.r.o.][link-author] +- [All Contributors][link-contributors] + +## License + +The MIT License (MIT). Please see [License File](LICENSE) for more information. + +[link-author]: https://github.com/digitalcz +[link-contributors]: ../../contributors diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..fa9ddc0 --- /dev/null +++ b/composer.json @@ -0,0 +1,66 @@ +{ + "name": "digitalcz/streams", + "type": "library", + "description": "Opinionated abstraction around PHP streams loosely implementing PSR-7 StreamInterface", + "keywords": [ + "streams", + "file" + ], + "homepage": "https://github.com/digitalcz/streams", + "license": "MIT", + "authors": [ + { + "name": "Digital Solutions s.r.o.", + "email": "devs@digital.cz", + "homepage": "https://digital.cz", + "role": "Developer" + }, + { + "name": "Pavel Stejskal", + "email": "pavel.stejskal@gmail.com", + "homepage": "https://github.com/spajxo", + "role": "Developer" + } + ], + "require": { + "php": "^8.1", + "psr/http-message": "^1.0.1" + }, + "require-dev": { + "digitalcz/coding-standard": "^0.0.1", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^1.9.0", + "phpstan/phpstan-phpunit": "^1.3.0", + "phpstan/phpstan-strict-rules": "^1.4.4", + "phpunit/phpunit": "^10.0.7", + "symfony/var-dumper": "^v6.2.0" + }, + "autoload": { + "psr-4": { + "DigitalCz\\Streams\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "DigitalCz\\Streams\\": "tests" + } + }, + "scripts": { + "tests": "phpunit", + "phpstan": "phpstan analyse", + "cs": "phpcs -p", + "csfix": "phpcbf -p", + "checks": [ + "@cs", + "@phpstan", + "@tests" + ] + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "phpstan/extension-installer": true, + "dealerdirect/phpcodesniffer-composer-installer": true + } + } +} diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..c78c780 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,14 @@ + + + src + tests + + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..03d47fc --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +parameters: + level: max + paths: + - src + - tests + + bootstrapFiles: + - vendor/autoload.php + + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..17b072f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,19 @@ + + + + + src/ + + + + + tests + + + diff --git a/src/File.php b/src/File.php new file mode 100644 index 0000000..641b311 --- /dev/null +++ b/src/File.php @@ -0,0 +1,54 @@ +path = $path; + + parent::__construct($stream, $size); + } + + public static function temp(): self + { + $path = tempnam(sys_get_temp_dir(), 'temp'); + + if (!is_string($path)) { + throw new StreamException('Failed to create temp name'); + } + + return new self($path); + } + + public function getPath(): string + { + return $this->path; + } + + public function delete(): void + { + $this->close(); + + if (@unlink($this->getPath()) === false) { + throw new StreamException('Failed to delete file ' . $this->getPath()); + } + } +} diff --git a/src/FileInterface.php b/src/FileInterface.php new file mode 100644 index 0000000..cf3ed79 --- /dev/null +++ b/src/FileInterface.php @@ -0,0 +1,12 @@ +size = $size; + $this->handle = $stream; + $meta = stream_get_meta_data($this->handle); + $this->seekable = $meta['seekable'] ?? false; // @phpstan-ignore-line + $this->readable = (bool)preg_match(self::READABLE_MODES, $meta['mode']); + $this->writable = (bool)preg_match(self::WRITABLE_MODES, $meta['mode']); + } + + /** + * @param string|null $key + * @return array|mixed|null + */ + public function getMetadata($key = null) + { + if (!isset($this->handle)) { + return $key !== null ? null : []; + } + + $meta = stream_get_meta_data($this->handle); + + return $key !== null ? ($meta[$key] ?? null) : $meta; + } + + public function close(): void + { + if (isset($this->handle)) { + if (is_resource($this->handle)) { + fclose($this->handle); + } + + $this->detach(); + } + } + + /** + * @return resource|null + */ + public function detach() + { + if (!isset($this->handle)) { + return null; + } + + $result = $this->handle; + unset($this->handle); + $this->size = null; + $this->readable = $this->writable = $this->seekable = false; + + return $result; + } + + public function isSeekable(): bool + { + return $this->seekable; + } + + /** + * @param int $offset + * @param int $whence + */ + public function seek($offset, $whence = SEEK_SET): void + { + $handle = $this->getHandle(); + + if (!$this->isSeekable()) { + throw new StreamException('Stream is not seekable'); + } + + if (fseek($handle, $offset, $whence) !== 0) { + throw new StreamException("Unable to seek to stream position {$offset} with whence {$whence}"); + } + } + + /** + * @return resource + */ + public function getHandle() + { + if (!isset($this->handle)) { + throw new StreamException('Stream is detached'); + } + + return $this->handle; + } + + public function getContents(): string + { + if ($this->isSeekable()) { + $this->rewind(); + } + + $contents = stream_get_contents($this->getHandle()); + + if (!is_string($contents)) { + throw new StreamException('Unable to read stream contents'); + } + + return $contents; + } + + public function getSize(): ?int + { + if ($this->size !== null) { + return $this->size; + } + + if (!isset($this->handle)) { + return null; + } + + $stats = fstat($this->handle); + + if (is_array($stats) && isset($stats['size'])) { // @phpstan-ignore-line + $this->size = $stats['size']; + + return $this->size; + } + + return null; + } + + public function isReadable(): bool + { + return $this->readable; + } + + public function isWritable(): bool + { + return $this->writable; + } + + public function eof(): bool + { + return feof($this->getHandle()); + } + + public function tell(): int + { + $result = ftell($this->getHandle()); + + if (!is_int($result)) { + throw new StreamException('Unable to determine stream position'); + } + + return $result; + } + + public function rewind(): void + { + $this->seek(0); + } + + /** + * @param int $length + */ + public function read($length): string + { + if ($length < 0) { + throw new StreamException('Length parameter cannot be negative'); + } + + $handle = $this->getHandle(); + + if (!$this->isReadable()) { + throw new StreamException('Stream is not readable'); + } + + if ($length === 0) { + return ''; + } + + $string = fread($handle, $length); + + if (!is_string($string)) { + throw new StreamException('Unable to read from stream'); + } + + return $string; + } + + /** + * @param string $string + */ + public function write($string): int + { + $handle = $this->getHandle(); + + if (!$this->isWritable()) { + throw new StreamException('Stream is not writable'); + } + + // We can't know the size after writing anything + $this->size = null; + $result = fwrite($handle, $string); + + if (!is_int($result)) { + throw new StreamException('Unable to write to stream'); + } + + return $result; + } + + public function copy(StreamInterface $source): int + { + if (!$source->isReadable()) { + throw new StreamException('Source stream is not readable'); + } + + if (!$this->isWritable()) { + throw new StreamException('Target stream is not writable'); + } + + $seekable = $source->isSeekable(); + + if ($seekable) { + $sourcePos = $source->tell(); + $source->rewind(); // rewind source to beginning + } + + // We can't know the size after writing anything + $this->size = null; + $bytes = stream_copy_to_stream($source->getHandle(), $this->getHandle()); + + if ($seekable) { + $source->seek($sourcePos); // forward source to previous position + } + + if (!is_int($bytes)) { + throw new StreamException('Failed to copy stream'); + } + + return $bytes; + } + + /** + * Closes the stream when the destructed + */ + public function __destruct() + { + $this->detach(); + } + + public function __toString(): string + { + return $this->getContents(); + } +} diff --git a/src/StreamException.php b/src/StreamException.php new file mode 100644 index 0000000..9cd6323 --- /dev/null +++ b/src/StreamException.php @@ -0,0 +1,11 @@ +|mixed|null + */ + public function getMetadata($key = null); + + public function close(): void; + + /** + * @return resource|null + */ + public function detach(); + + public function isSeekable(): bool; + + /** + * @param int $offset + * @param int $whence + */ + public function seek($offset, $whence = SEEK_SET): void; + + /** + * @return resource + */ + public function getHandle(); + + public function getContents(): string; + + public function getSize(): ?int; + + public function isReadable(): bool; + + public function isWritable(): bool; + + public function eof(): bool; + + public function tell(): int; + + public function rewind(): void; + + /** + * @param int $length + */ + public function read($length): string; + + /** + * @param string $string + */ + public function write($string): int; + + public function copy(Stream $source): int; +} diff --git a/src/TempFile.php b/src/TempFile.php new file mode 100644 index 0000000..5f3b133 --- /dev/null +++ b/src/TempFile.php @@ -0,0 +1,55 @@ +getMetadata('uri'); + + if (!is_string($path)) { + throw new StreamException('Unable to create TempFile'); + } + + $this->path = $path; + } + + public static function fromStream(StreamInterface $stream): self + { + $self = new self(); + $self->copy($stream); + $self->rewind(); + + return $self; + } + + public function getPath(): string + { + return $this->path; + } + + public function delete(): void + { + $this->close(); + } +} diff --git a/src/TempStream.php b/src/TempStream.php new file mode 100644 index 0000000..aaefdd9 --- /dev/null +++ b/src/TempStream.php @@ -0,0 +1,28 @@ +copy($stream); + $self->rewind(); + + return $self; + } +} diff --git a/tests/FileTest.php b/tests/FileTest.php new file mode 100644 index 0000000..e35cc08 --- /dev/null +++ b/tests/FileTest.php @@ -0,0 +1,27 @@ +expectException(StreamException::class); + $this->expectExceptionMessage('Failed to open file'); + new File('foo', 'r'); + } + + public function testCreateAndDelete(): void + { + $path = tempnam(sys_get_temp_dir(), 'temp'); + self::assertNotFalse($path); + $file = new File($path); + self::assertFileExists($file->getPath()); + $file->delete(); + self::assertFileDoesNotExist($file->getPath()); + } +} diff --git a/tests/StreamTest.php b/tests/StreamTest.php new file mode 100644 index 0000000..de9c253 --- /dev/null +++ b/tests/StreamTest.php @@ -0,0 +1,416 @@ +expectException(StreamException::class); + new Stream(true); // @phpstan-ignore-line + } + + public function testConstructorInitializesProperties(): void + { + $handle = $this->createTempResource('r+'); + fwrite($handle, 'data'); + $stream = new Stream($handle); + self::assertTrue($stream->isReadable()); + self::assertTrue($stream->isWritable()); + self::assertTrue($stream->isSeekable()); + self::assertSame('php://temp', $stream->getMetadata('uri')); + self::assertIsArray($stream->getMetadata()); + self::assertSame(4, $stream->getSize()); + self::assertFalse($stream->eof()); + $stream->close(); + } + + public function testConstructorInitializesPropertiesWithRbPlus(): void + { + $handle = $this->createTempResource('rb+'); + fwrite($handle, 'data'); + $stream = new Stream($handle); + self::assertTrue($stream->isReadable()); + self::assertTrue($stream->isWritable()); + self::assertTrue($stream->isSeekable()); + self::assertSame('php://temp', $stream->getMetadata('uri')); + self::assertIsArray($stream->getMetadata()); + self::assertSame(4, $stream->getSize()); + self::assertFalse($stream->eof()); + $stream->close(); + } + + public function testStreamDoesNotCloseAutomaticallyAfterDestroy(): void + { + $handle = $this->createTempResource('r'); + $stream = new Stream($handle); + unset($stream); + self::assertIsResource($handle); // @phpstan-ignore-line + } + + public function testConvertsToString(): void + { + $handle = $this->createTempResource('w+'); + fwrite($handle, 'data'); + $stream = new Stream($handle); + self::assertSame('data', (string)$stream); + self::assertSame('data', (string)$stream); + $stream->close(); + } + + public function testConvertsToStringNonSeekableStream(): void + { + $handle = popen('echo foo', 'r'); + $stream = new Stream($handle); // @phpstan-ignore-line + self::assertFalse($stream->isSeekable()); + self::assertSame('foo', trim((string)$stream)); + } + + public function testConvertsToStringNonSeekablePartiallyReadStream(): void + { + $handle = popen('echo bar', 'r'); + $stream = new Stream($handle); // @phpstan-ignore-line + $firstLetter = $stream->read(1); + self::assertFalse($stream->isSeekable()); + self::assertSame('b', $firstLetter); + self::assertSame('ar', trim((string)$stream)); + } + + public function testGetsContents(): void + { + $handle = $this->createTempResource('w+'); + fwrite($handle, 'data'); + $stream = new Stream($handle); + self::assertSame('data', $stream->getContents()); + $stream->close(); + } + + public function testChecksEof(): void + { + $handle = $this->createTempResource('w+'); + fwrite($handle, 'data'); + $stream = new Stream($handle); + self::assertSame(4, $stream->tell(), 'Stream cursor already at the end'); + self::assertFalse($stream->eof(), 'Stream still not eof'); + self::assertSame('', $stream->read(1), 'Need to read one more byte to reach eof'); + self::assertTrue($stream->eof()); + $stream->close(); + } + + public function testGetSize(): void + { + $size = filesize(__FILE__); + $handle = fopen(__FILE__, 'r'); + $stream = new Stream($handle); // @phpstan-ignore-line + self::assertSame($size, $stream->getSize()); + // Load from cache + self::assertSame($size, $stream->getSize()); + $stream->close(); + } + + public function testEnsuresSizeIsConsistent(): void + { + $h = $this->createTempResource('w+'); + self::assertSame(3, fwrite($h, 'foo')); + $stream = new Stream($h); + self::assertSame(3, $stream->getSize()); + self::assertSame(4, $stream->write('test')); + self::assertSame(7, $stream->getSize()); + self::assertSame(7, $stream->getSize()); + $stream->close(); + } + + public function testProvidesStreamPosition(): void + { + $handle = $this->createTempResource('w+'); + $stream = new Stream($handle); + self::assertSame(0, $stream->tell()); + $stream->write('foo'); + self::assertSame(3, $stream->tell()); + $stream->seek(1); + self::assertSame(1, $stream->tell()); + self::assertSame(ftell($handle), $stream->tell()); + $stream->close(); + } + + public function testDetachStreamAndClearProperties(): void + { + $handle = $this->createTempResource('r'); + $stream = new Stream($handle); + self::assertSame($handle, $stream->detach()); + self::assertIsNotClosedResource($handle); + self::assertNull($stream->detach()); + + $this->assertStreamStateAfterClosedOrDetached($stream); + + $stream->close(); + } + + public function testCloseResourceAndClearProperties(): void + { + $handle = $this->createTempResource('r'); + $stream = new Stream($handle); + $stream->close(); + + self::assertIsClosedResource($handle); + + $this->assertStreamStateAfterClosedOrDetached($stream); + } + + public function testStreamReadingWithZeroLength(): void + { + $r = $this->createTempResource('r'); + $stream = new Stream($r); + + self::assertSame('', $stream->read(0)); + + $stream->close(); + } + + public function testStreamReadingWithNegativeLength(): void + { + $r = $this->createTempResource('r'); + $stream = new Stream($r); + $this->expectException(StreamException::class); + $this->expectExceptionMessage('Length parameter cannot be negative'); + + try { + $stream->read(-1); + } catch (Throwable $e) { + $stream->close(); + + throw $e; + } + + $stream->close(); + } + + #[RequiresPhpExtension('zlib')] + #[DataProvider('gzipModeProvider')] + public function testGzipStreamModes(string $mode, bool $readable, bool $writable): void + { + $r = gzopen('php://temp', $mode); + $stream = new Stream($r); // @phpstan-ignore-line + + self::assertSame($readable, $stream->isReadable()); + self::assertSame($writable, $stream->isWritable()); + + $stream->close(); + } + + /** + * @return iterable + */ + public static function gzipModeProvider(): iterable + { + return [ + ['mode' => 'rb9', 'readable' => true, 'writable' => false], + ['mode' => 'wb2', 'readable' => false, 'writable' => true], + ]; + } + + #[DataProvider('readableModeProvider')] + public function testReadableStream(string $mode): void + { + $r = $this->createTempResource($mode); + $stream = new Stream($r); + + self::assertTrue($stream->isReadable()); + + $stream->close(); + } + + /** + * @return iterable> + */ + public static function readableModeProvider(): iterable + { + return [ + ['r'], + ['w+'], + ['r+'], + ['x+'], + ['c+'], + ['rb'], + ['w+b'], + ['r+b'], + ['x+b'], + ['c+b'], + ['rt'], + ['w+t'], + ['r+t'], + ['x+t'], + ['c+t'], + ['a+'], + ['rb+'], + ]; + } + + public function testWriteOnlyStreamIsNotReadable(): void + { + $r = fopen('php://output', 'w'); + $stream = new Stream($r); // @phpstan-ignore-line + + self::assertFalse($stream->isReadable()); + + $stream->close(); + } + + #[DataProvider('writableModeProvider')] + public function testWritableStream(string $mode): void + { + $r = $this->createTempResource($mode); + $stream = new Stream($r); + + self::assertTrue($stream->isWritable()); + + $stream->close(); + } + + /** + * @return iterable> + */ + public static function writableModeProvider(): iterable + { + return [ + ['w'], + ['w+'], + ['rw'], + ['r+'], + ['x+'], + ['c+'], + ['wb'], + ['w+b'], + ['r+b'], + ['rb+'], + ['x+b'], + ['c+b'], + ['w+t'], + ['r+t'], + ['x+t'], + ['c+t'], + ['a'], + ['a+'], + ]; + } + + public function testReadOnlyStreamIsNotWritable(): void + { + $r = fopen('php://input', 'rb'); + $stream = new Stream($r); // @phpstan-ignore-line + + self::assertFalse($stream->isWritable()); + + $stream->close(); + } + + public function testCopySourceNotReadable(): void + { + $target = new Stream($this->createTempResource('wb+')); + $source = new Stream(fopen('php://output', 'wb')); // @phpstan-ignore-line + + $this->expectException(StreamException::class); + $this->expectExceptionMessage('Source stream is not readable'); + $target->copy($source); + } + + public function testCopyTargetNotWritable(): void + { + $target = new Stream($this->createTempResource('rb')); + $source = new Stream($this->createTempResource('wb+')); + + $this->expectException(StreamException::class); + $this->expectExceptionMessage('Target stream is not writable'); + $target->copy($source); + } + + public function testCopy(): void + { + $target = new Stream($this->createTempResource('wb+')); + $source = new Stream($this->createTempResource('wb+')); + + $source->write('test'); + $target->copy($source); + $target->rewind(); + self::assertEquals('test', $target->getContents()); + } + + public function testCopyPurgeSize(): void + { + $target = new Stream($this->createTempResource('wb+')); + $source = new Stream($this->createTempResource('wb+')); + + $source->write('test'); + $sizeBefore = $target->getSize(); + $target->copy($source); + $target->rewind(); + + self::assertNotEquals($sizeBefore, $target->getSize()); + } + + private function assertStreamStateAfterClosedOrDetached(Stream $stream): void + { + self::assertFalse($stream->isReadable()); + self::assertFalse($stream->isWritable()); + self::assertFalse($stream->isSeekable()); + self::assertNull($stream->getSize()); + self::assertSame([], $stream->getMetadata()); + self::assertNull($stream->getMetadata('foo')); + + $throws = static function (callable $fn): void { + try { + $fn(); + } catch (Throwable $e) { + self::assertStringContainsString('Stream is detached', $e->getMessage()); + + return; + } + + self::fail('Exception should be thrown after the stream is detached.'); + }; + + $throws(static function () use ($stream): void { + $stream->read(10); + }); + $throws(static function () use ($stream): void { + $stream->write('bar'); + }); + $throws(static function () use ($stream): void { + $stream->seek(10); + }); + $throws(static function () use ($stream): void { + $stream->tell(); + }); + $throws(static function () use ($stream): void { + $stream->eof(); + }); + $throws(static function () use ($stream): void { + $stream->getContents(); + }); + $throws(static function () use ($stream): void { + $stream->__toString(); + }); + } + + /** + * @return resource + */ + private function createTempResource(string $mode) + { + $handle = fopen('php://temp', $mode); + + if ($handle === false) { + throw new LogicException(); + } + + return $handle; + } +} diff --git a/tests/TempFileTest.php b/tests/TempFileTest.php new file mode 100644 index 0000000..3d9871d --- /dev/null +++ b/tests/TempFileTest.php @@ -0,0 +1,28 @@ +getPath()); + $file->delete(); + self::assertFileDoesNotExist($file->getPath()); + } + + public function testFromStream(): void + { + $stream = new TempStream(); + $stream->write('pokus'); + $file = TempFile::fromStream($stream); + + self::assertEquals(0, $file->tell()); + self::assertEquals('pokus', $file->getContents()); + } +} diff --git a/tests/TempStreamTest.php b/tests/TempStreamTest.php new file mode 100644 index 0000000..de70d55 --- /dev/null +++ b/tests/TempStreamTest.php @@ -0,0 +1,30 @@ +write($bytes); + self::assertEquals(10, $temp->getSize()); + $temp->rewind(); + self::assertEquals($bytes, $temp->getContents()); + } + + public function testFromStream(): void + { + $source = new TempStream(); + $source->write('pokus'); + $stream = TempStream::fromStream($source); + + self::assertEquals(0, $stream->tell()); + self::assertEquals('pokus', $stream->getContents()); + } +}