diff --git a/CHANGELOG.md b/CHANGELOG.md index a85672f..26898f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +0.4.2 / + - Refactor `PrettyPrint` to delegate `format3DTorch` logic to `Formatter` for consistency + - Add and update tests in `FormatterTest` + - Refactor `Formatter` and `PrettyPrint` to use `Env::isCli()` for CLI detection + - introduce `Env` utility class with tests; update `FormatterTest` to validate new behavior + - Add support for custom separators in `PrettyPrint`; update tests to cover new functionality + 0.4.1 / 2025-12-02 - Enhance `PrettyPrint::formatValue` to handle objects, resources, and unknown types - Revise `PrettyPrint` to output top-level strings without quotes diff --git a/README.md b/README.md index 90e078c..7b6e013 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,15 @@ pprint('Add 2 lines', end: "\n\n"); pprint('Tabbed', start: "\t"); // Combine with end to avoid newline pprint('Prompted', start: '>>> ', end: ''); + +// Custom separator between multiple values (default is a single space " ") +pprint('A', 'B', 'C', sep: ', ', end: ''); +// A, B, C + +// Separator can also be provided via trailing options array +pprint('X', 'Y', ['sep' => "\n", 'end' => '']); +// X +// Y ``` Print and then exit the script @@ -159,6 +168,7 @@ Notes: - **start**: string. Prefix printed before the content. Example: `pprint('Hello', ['start' => "\t"])`. - **end**: string. Line terminator, default to new line. Example: `pprint('no newline', ['end' => '']);` +- **sep**: string. Separator between multiple default-formatted arguments. Default is a single space `' '`. Examples: `pprint('A','B','C', sep: ', ', end: '')` or `pprint('X','Y', ['sep' => "\n", 'end' => ''])`. - **label**: string. Prefix label for 2D/3D formatted arrays, default `tensor`. Example: `pprint($m, ['label' => 'arr'])`. - **precision**: int. Number of digits after the decimal point for floats. Example: `pprint(3.14159, precision: 2)` prints `3.14`. - **headB / tailB**: ints. Number of head/tail 2D blocks shown for 3D tensors. @@ -171,6 +181,7 @@ All options can be passed as: #### Defaults - **label**: `tensor` +- **sep**: `' '` - **precision**: `4` - **headB / tailB**: `5` - **headRows / tailRows**: `5` diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 4c32073..245618d 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -11,7 +11,7 @@ - + diff --git a/phpunit.xml b/phpunit.xml index 7cc5a1d..6338af4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -26,4 +26,7 @@ + + + diff --git a/src/Env.php b/src/Env.php new file mode 100644 index 0000000..a94c72a --- /dev/null +++ b/src/Env.php @@ -0,0 +1,38 @@ + for web (non-CLI) usage - $isCli = (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg'); + $isCli = Env::isCli(); if (!$isCli) { $start = '
' . $start;
             $end = $end . '
'; @@ -160,7 +168,7 @@ public function __invoke(...$args) // Label + single 3D tensor if (count($args) === 2 && !is_array($args[0]) && is_array($args[1]) && Validator::is3D($args[1])) { - $out = $this->format3DTorch( + $out = Formatter::format3DTorch( $args[1], (int)($fmt['headB'] ?? 5), (int)($fmt['tailB'] ?? 5), @@ -168,7 +176,8 @@ public function __invoke(...$args) (int)($fmt['tailRows'] ?? 5), (int)($fmt['headCols'] ?? 5), (int)($fmt['tailCols'] ?? 5), - (string)($fmt['label'] ?? 'tensor') + (string)($fmt['label'] ?? 'tensor'), + $this->precision ); echo $start . $out . $end; $this->precision = $prevPrecision; @@ -216,7 +225,7 @@ public function __invoke(...$args) foreach ($args as $arg) { if (is_array($arg)) { if (Validator::is3D($arg)) { - $parts[] = $this->format3DTorch( + $parts[] = Formatter::format3DTorch( $arg, (int)($fmt['headB'] ?? 5), (int)($fmt['tailB'] ?? 5), @@ -224,7 +233,8 @@ public function __invoke(...$args) (int)($fmt['tailRows'] ?? 5), (int)($fmt['headCols'] ?? 5), (int)($fmt['tailCols'] ?? 5), - (string)($fmt['label'] ?? 'tensor') + (string)($fmt['label'] ?? 'tensor'), + $this->precision ); } elseif (Validator::is2D($arg)) { $parts[] = Formatter::format2DTorch( @@ -244,68 +254,7 @@ public function __invoke(...$args) } } - echo $start . implode(' ', $parts) . $end; + echo $start . implode($sep, $parts) . $end; $this->precision = $prevPrecision; } - - // ---- Private helpers ---- - - /** - * Format a 3D numeric tensor in a PyTorch-like multiline representation. - * - * @param array $tensor3d 3D array of ints/floats. - * @param int $headB Number of head 2D slices to display. - * @param int $tailB Number of tail 2D slices to display. - * @param int $headRows Number of head rows per 2D slice. - * @param int $tailRows Number of tail rows per 2D slice. - * @param int $headCols Number of head columns per 2D slice. - * @param int $tailCols Number of tail columns per 2D slice. - * @param string $label Prefix label used instead of "tensor". - * @return string - */ - private function format3DTorch(array $tensor3d, int $headB = 5, int $tailB = 5, int $headRows = 5, int $tailRows = 5, int $headCols = 5, int $tailCols = 5, string $label = 'tensor'): string - { - $B = count($tensor3d); - $idxs = []; - $useBEllipsis = false; - if ($B <= $headB + $tailB) { - for ($i = 0; $i < $B; $i++) { - $idxs[] = $i; - } - } else { - for ($i = 0; $i < $headB; $i++) { - $idxs[] = $i; - } - $useBEllipsis = true; - for ($i = $B - $tailB; $i < $B; $i++) { - $idxs[] = $i; - } - } - - $blocks = []; - $format2d = function ($matrix) use ($headRows, $tailRows, $headCols, $tailCols) { - return Formatter::format2DSummarized($matrix, $headRows, $tailRows, $headCols, $tailCols, $this->precision); - }; - - $limitHead = ($B <= $headB + $tailB) ? count($idxs) : $headB; - for ($i = 0; $i < $limitHead; $i++) { - $formatted2d = $format2d($tensor3d[$idxs[$i]]); - // Indent entire block by a single space efficiently - $blocks[] = ' ' . str_replace("\n", "\n ", $formatted2d); - } - if ($useBEllipsis) { - $blocks[] = ' ...'; - } - if ($useBEllipsis) { - for ($i = $limitHead; $i < count($idxs); $i++) { - $formatted2d = $format2d($tensor3d[$idxs[$i]]); - $blocks[] = ' ' . str_replace("\n", "\n ", $formatted2d); - } - } - - $joined = implode(",\n\n ", $blocks); - return $label . "([\n " . $joined . "\n])"; - } } - -// 672/605/499/312== diff --git a/src/functions.php b/src/functions.php index 21cf00b..9c3a46b 100644 --- a/src/functions.php +++ b/src/functions.php @@ -34,6 +34,11 @@ function pp(...$args): void function ppd(...$args): void { pprint(...$args); + + if (PHP_SAPI === 'cli' && getenv('APP_ENV') === 'test') { + return; + } + exit; } } diff --git a/tests/EnvTest.php b/tests/EnvTest.php new file mode 100644 index 0000000..4db6f09 --- /dev/null +++ b/tests/EnvTest.php @@ -0,0 +1,71 @@ + true, otherwise false)')] + public function testIsCliMatchesCurrentSapi(): void + { + $expected = (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg'); + self::assertSame($expected, Env::isCli()); + } + + #[Test] + #[TestDox('isCli() returns a boolean')] + public function testIsCliReturnsBool(): void + { + self::assertIsBool(Env::isCli()); + } + + #[Test] + #[TestDox('setCliOverride() forces isCli() result and can be reset')] + public function testSetCliOverrideForcesAndResets(): void + { + Env::setCliOverride(false); + self::assertFalse(Env::isCli()); + Env::setCliOverride(true); + self::assertTrue(Env::isCli()); + Env::setCliOverride(null); + $expected = (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg'); + self::assertSame($expected, Env::isCli()); + } + + #[Test] + #[TestDox('isTest() returns true when APP_ENV=test under CLI')] + public function testIsTestTrueWhenAppEnvTest(): void + { + $prev = getenv('APP_ENV'); + try { + putenv('APP_ENV=test'); + self::assertSame(PHP_SAPI === 'cli', Env::isTest()); + } finally { + putenv('APP_ENV' . ($prev === false ? '' : '=' . $prev)); + } + } + + #[Test] + #[TestDox('isTest() returns false when APP_ENV is not test')] + public function testIsTestFalseWhenAppEnvNotTest(): void + { + $prev = getenv('APP_ENV'); + try { + putenv('APP_ENV=dev'); + self::assertFalse(Env::isTest()); + } finally { + putenv('APP_ENV' . ($prev === false ? '' : '=' . $prev)); + } + } +} diff --git a/tests/FormatterTest.php b/tests/FormatterTest.php index 60f86c0..8fdb064 100644 --- a/tests/FormatterTest.php +++ b/tests/FormatterTest.php @@ -5,6 +5,7 @@ namespace Apphp\PrettyPrint\Tests; use Apphp\PrettyPrint\Formatter; +use Apphp\PrettyPrint\Env; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -194,4 +195,99 @@ public function testFormat2DTorch(array $matrix, int $headRows, int $tailRows, i { self::assertSame($expected, Formatter::format2DTorch($matrix, $headRows, $tailRows, $headCols, $tailCols, $label, $precision)); } + + public static function format3DTorchProvider(): array + { + return [ + 'two 2x2 blocks, no truncation' => [ + [[[1, 2], [3, 4]], [[5, 6], [7, 8]]], + 5, 5, 5, 5, 5, 5, + 'tensor', + 2, + "tensor([\n [[1, 2],\n [3, 4]],\n\n [[5, 6],\n [7, 8]]\n])", + ], + 'block ellipsis with inner 2D ellipses' => [ + [ + [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + ], + 1, 1, 1, 1, 1, 1, + 'tensor', + 2, + "tensor([\n [[1, ..., 3],\n ...,\n [7, ..., 9]],\n\n ...,\n\n [[1, ..., 3],\n ...,\n [7, ..., 9]]\n])", + ], + ]; + } + + #[Test] + #[TestDox('format3DTorch wraps multiple 2D blocks with proper spacing, supports block ellipsis and inner 2D summarization')] + #[DataProvider('format3DTorchProvider')] + public function testFormat3DTorch(array $tensor3d, int $headB, int $tailB, int $headRows, int $tailRows, int $headCols, int $tailCols, string $label, int $precision, string $expected): void + { + self::assertSame($expected, Formatter::format3DTorch($tensor3d, $headB, $tailB, $headRows, $tailRows, $headCols, $tailCols, $label, $precision)); + } + + public static function formatForArrayProvider(): array + { + return [ + 'empty array' => [ + [], + 2, + '[]', + ], + '1D mixed scalars with precision for floats' => [ + [1, "a'b", true, null, 1.2], + 2, + "[1, a'b, True, None, 1.20]", + ], + 'nested non-2D arrays recurse' => [ + [[1, 2], 3, [4, [5.5]]], + 1, + '[[1, 2], 3, [4, [5.50]]]', + ], + '2D numeric uses aligned formatting' => [ + [[1, 23], [3, 4]], + 2, + "[[1, 23],\n [3, 4]]", + ], + '2D with mixed types falls back to recursive array formatting (not 2D)' => [ + [["a'b", false, (object)[]], [fopen('php://memory', 'r')]], + 2, + "[[a'b, False, Object], [Resource]]", + ], + ]; + } + + #[Test] + #[TestDox('formatForArray formats arrays recursively; 2D arrays use aligned formatting; scalars via formatCell')] + #[DataProvider('formatForArrayProvider')] + public function testFormatForArray(mixed $value, int $precision, string $expected): void + { + self::assertSame($expected, Formatter::formatForArray($value, $precision)); + } + + #[Test] + #[TestDox('formatCell escapes strings with addslashes() when not CLI (web context)')] + public function testFormatCellEscapesInWebContext(): void + { + Env::setCliOverride(false); + try { + self::assertSame("a\\'b", Formatter::formatCell("a'b", 2)); + } finally { + Env::setCliOverride(null); + } + } + + #[Test] + #[TestDox('formatCell returns raw strings in CLI context')] + public function testFormatCellRawInCliContext(): void + { + Env::setCliOverride(true); + try { + self::assertSame("a'b", Formatter::formatCell("a'b", 2)); + } finally { + Env::setCliOverride(null); + } + } } diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index b932948..146b5e6 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -9,6 +9,8 @@ use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\CoversFunction; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\Attributes\PreserveGlobalState; #[Group('prettyprint')] #[CoversFunction('pprint')] @@ -35,4 +37,14 @@ public function ppIsAlias(): void $out = ob_get_clean(); self::assertSame("1 2 3\n", $out); } + + #[Test] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + #[TestDox('global function ppd prints output (no exit in test env)')] + public function ppdPrintsThenExits(): void + { + $this->expectOutputString("hello\n"); + ppd('hello'); + } } diff --git a/tests/PrettyPrintTest.php b/tests/PrettyPrintTest.php index aaed041..b0d8e63 100644 --- a/tests/PrettyPrintTest.php +++ b/tests/PrettyPrintTest.php @@ -5,6 +5,7 @@ namespace Apphp\PrettyPrint\Tests; use Apphp\PrettyPrint\PrettyPrint; +use Apphp\PrettyPrint\Env; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestDox; @@ -43,6 +44,28 @@ public function scalarsAndStrings(): void self::assertSame("Hello 123 4.5600\n", $out); } + #[Test] + #[TestDox('supports custom separator via named argument (sep)')] + public function customSeparatorNamed(): void + { + $pp = new PrettyPrint(); + ob_start(); + $pp('A', 'B', 'C', sep: ', ', end: ''); + $out = ob_get_clean(); + self::assertSame('A, B, C', $out); + } + + #[Test] + #[TestDox('supports custom separator via trailing options array (sep)')] + public function customSeparatorTrailingArray(): void + { + $pp = new PrettyPrint(); + ob_start(); + $pp('X', 'Y', ['sep' => "\n", 'end' => '']); + $out = ob_get_clean(); + self::assertSame("X\nY", $out); + } + #[Test] #[TestDox('accepts named formatting args for 2D (headRows/tailRows/headCols/tailCols)')] public function namedFormattingOptions2D(): void @@ -353,4 +376,72 @@ public function multipleRowsWithLabel(): void $out = ob_get_clean(); self::assertSame("Label\n[[1, 2],\n [3, 4]]\n", $out); } + + #[Test] + #[TestDox('limits the number of variadic arguments to MAX_ARGS (32)')] + public function limitsMaxArgs(): void + { + $pp = new PrettyPrint(); + $args = range(1, 40); + ob_start(); + $pp(...$args); + $out = ob_get_clean(); + $expected = implode(' ', array_map(static fn ($n) => (string)$n, range(1, 32))) . "\n"; + self::assertSame($expected, $out); + } + + #[Test] + #[TestDox('removes unknown named arguments so they do not print as stray scalars')] + public function unknownNamedArgsAreRemoved(): void + { + $pp = new PrettyPrint(); + ob_start(); + // foo and baz are unknown named args and should be stripped + $pp('Hello', foo: 'bar', baz: 123); + $out = ob_get_clean(); + self::assertSame("Hello\n", $out); + } + + #[Test] + #[TestDox('auto-wraps output with
...
in non-CLI (web) context')] + public function autoWrapsPreInWebContext(): void + { + $pp = new PrettyPrint(); + Env::setCliOverride(false); + try { + ob_start(); + $pp('Hello', end: ''); + $out = ob_get_clean(); + self::assertSame('
Hello
', $out); + } finally { + Env::setCliOverride(null); + } + } + + #[Test] + #[TestDox('does not wrap with
 in CLI context')]
+    public function noPreWrapInCliContext(): void
+    {
+        $pp = new PrettyPrint();
+        Env::setCliOverride(true);
+        try {
+            ob_start();
+            $pp('Hello', end: '');
+            $out = ob_get_clean();
+            self::assertSame('Hello', $out);
+        } finally {
+            Env::setCliOverride(null);
+        }
+    }
+
+    #[Test]
+    #[TestDox('invokes PrettyPrint via explicit __invoke() method call')]
+    public function invokeMethodDirectly(): void
+    {
+        $pp = new PrettyPrint();
+        ob_start();
+        $pp->__invoke('Direct');
+        $out = ob_get_clean();
+        self::assertSame("Direct\n", $out);
+    }
 }