diff --git a/CHANGELOG.md b/CHANGELOG.md index 8613f00..69fa2aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +0.5.1 / 2025-12-12 + - Improve tensor block formatting in `Formatter` for PyTorch-style visualization + - Refactor `PrettyPrint` for simplified default formatting, remove redundant label-based handling + - Added new function pdiff() for array comparison + 0.5.0 / 2025-12-08 - Refactored `ppd` function for improved CLI test handling with conditional exit strategy - Added support for automatic object-to-array conversion in `PrettyPrint` (`asArray()`/`toArray()`) diff --git a/README.md b/README.md index af37473..2ee6fb4 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ use function Apphp\PrettyPrint\ppd; ``` or simply ```php -use function Apphp\PrettyPrint\{pprint, pp, ppd}; +use function Apphp\PrettyPrint\{pprint, pp, ppd, pdiff}; ``` ### Global helper functions @@ -33,19 +33,37 @@ pprint('Hello', 123, 4.56); // Hello 123 4.5600 ``` -Print multiple 1D rows aligned as a 2D table +Print 1D arrays ```php pprint([1, 23, 456], [12, 3, 45]); -// [[ 1, 23, 456], -// [12, 3, 45]] +// [1, 23, 456] +// [12, 3, 45] +``` + +Compare two arrays with a difference matrix +```php +$a = [ + [1, 2, 3], + [4, 5, 6], +]; + +$b = [ + [1, 9, 3], + [0, 5, 7], +]; + +pdiff($a, $b); +// [[1, -, 3], +// [-, 5, -]] ``` Label + 2D matrix ```php -pprint('Confusion matrix:', [[1, 23], [456, 7]]); -// Confusion matrix: -// [[ 1, 23], -// [456, 7]] +pprint([[1, 23], [456, 7]], label: 'Confusion matrix:'); +// Confusion matrix:([ +// [ 1, 23], +// [456, 7] +// ]) ``` 2D tensor-style formatting @@ -102,9 +120,7 @@ pprint($tensor3d, headB: 1, tailB: 1, headRows: 1, tailRows: 1, headCols: 1, tai // tensor([ // [[1, ..., 3], // [4, ..., 6]], -// // ..., -// // [[13, ..., 15], // [16, ..., 18]] // ]) @@ -172,7 +188,7 @@ $pp('Metrics:', [[0.91, 0.02], [0.03, 0.88]]); If you pass an object that exposes an `asArray()` or `toArray()` method, `pprint` / `PrettyPrint` will automatically convert it to an array before formatting: ```php -class UserCollection +class Users { public function asArray(): array { @@ -183,11 +199,13 @@ class UserCollection } } -$users = new UserCollection(); +$users = new Users(); pprint($users); -// [[1, 'Alice'], -// [2, 'Bob']] +// Users([ +// [1, Alice], +// [2, Bob] +// ]) ``` If `asArray()` is not present but `toArray()` is, `toArray()` will be used instead. diff --git a/src/Formatter.php b/src/Formatter.php index 48ae0b6..0477432 100644 --- a/src/Formatter.php +++ b/src/Formatter.php @@ -322,7 +322,9 @@ public static function format3DTorch(array $tensor3d, int $headB = 5, int $tailB } } - $joined = implode(",\n\n ", $blocks); + // Use a single newline between blocks for simple tensors without batch + $separator = ",\n "; + $joined = implode($separator, $blocks); return $label . "([\n " . $joined . "\n])"; } } diff --git a/src/PrettyPrint.php b/src/PrettyPrint.php index db3c28b..736e977 100644 --- a/src/PrettyPrint.php +++ b/src/PrettyPrint.php @@ -4,6 +4,8 @@ namespace Apphp\PrettyPrint; +use ReflectionException; + /** * PrettyPrint * @@ -39,6 +41,7 @@ class PrettyPrint private const MAX_LABEL_LEN = 50; private const MAX_ARGS = 32; private int $precision = 4; + private array $labels = []; /** * Invoke the pretty printer. @@ -88,11 +91,7 @@ public function __invoke(...$args): string $this->precision = max(0, (int) $fmt['precision']); } - $out = - $this->tryLabel3D($args, $fmt, $start, $end) - ?? $this->tryLabel2D($args, $start, $end) - ?? $this->tryMultiple1DRows($args, $start, $end) - ?? $start . implode($sep, $this->formatDefaultParts($args, $fmt)) . $end; + $out = $start . implode($sep, $this->formatDefaultParts($args, $fmt)) . $end; // Restore default precision $this->precision = $prevPrecision; @@ -240,6 +239,7 @@ private function applyAutoPreWrapping(bool $isCli, string &$start, string &$end) * * @param array $args * @return array + * @throws ReflectionException */ private function normalizeArgs(array $args): array { @@ -252,103 +252,39 @@ private function normalizeArgs(array $args): array // Convert objects to arrays if possible foreach ($args as $i => $value) { - if (is_object($value)) { - if (is_callable([$value, 'asArray'])) { - $args[$i] = $value->asArray(); - } elseif (is_callable([$value, 'toArray'])) { - $args[$i] = $value->toArray(); - } + if (!is_object($value)) { + continue; } - } - if (count($args) > self::MAX_ARGS) { - $args = array_slice($args, 0, self::MAX_ARGS); - } - return $args; - } + $array = null; - /** - * Handle the special case: label + single 3D tensor. - * Echoes output and restores precision if matched. - * - * @param array $args - * @param array $fmt - * @param string $start - * @param string $end - * @return string|null True if handled, false otherwise - */ - private function tryLabel3D(array $args, array $fmt, string $start, string $end): ?string - { - if (count($args) === 2 && !is_array($args[0]) && is_array($args[1]) && Validator::is3D($args[1])) { - $out = Formatter::format3DTorch( - $args[1], - (int)($fmt['headB'] ?? 5), - (int)($fmt['tailB'] ?? 5), - (int)($fmt['headRows'] ?? 5), - (int)($fmt['tailRows'] ?? 5), - (int)($fmt['headCols'] ?? 5), - (int)($fmt['tailCols'] ?? 5), - (string)($fmt['label'] ?? 'tensor'), - $this->precision - ); - return $start . $out . $end; - } - return null; - } - - /** - * Handle the special case: label + single 2D matrix. - * Echoes output and restores precision if matched. - * - * @param array $args - * @param string $start - * @param string $end - * @return string|null True if handled, false otherwise - */ - private function tryLabel2D(array $args, string $start, string $end): ?string - { - if (count($args) === 2 && !is_array($args[0]) && is_array($args[1]) && Validator::is2D($args[1])) { - $label = is_bool($args[0]) ? ($args[0] ? 'True' : 'False') : (is_null($args[0]) ? 'None' : (string)$args[0]); - $out = Formatter::format2DAligned($args[1], $this->precision); - return $start . ($label . "\n" . $out) . $end; - } - return null; - } + if (is_callable([$value, 'asArray'])) { + $array = $value->asArray(); + } elseif (is_callable([$value, 'toArray'])) { + $array = $value->toArray(); + } - /** - * Handle the case of multiple 1D rows (optionally preceded by a label) and align them. - * Echoes output and restores precision if matched. - * - * @param array $args - * @param string $start - * @param string $end - * @return string|null True if handled, false otherwise - */ - private function tryMultiple1DRows(array $args, string $start, string $end): ?string - { - $label = null; - $rows = []; - if (count($args) > 1) { - $startIndex = 0; - if (!is_array($args[0])) { - $label = is_bool($args[0]) ? ($args[0] ? 'True' : 'False') : (is_null($args[0]) ? 'None' : (string)$args[0]); - $startIndex = 1; + if (!is_array($array)) { + continue; } - $allRows = true; - for ($i = $startIndex; $i < count($args); $i++) { - if (Validator::is1D($args[$i])) { - $rows[] = $args[$i]; - } else { - $allRows = false; - break; + + // If this is a 2D structure with associative rows, normalize rows to indexed arrays + if (Validator::is2D($array)) { + foreach ($array as $rowIndex => $row) { + if (is_array($row) && !array_is_list($row)) { + $array[$rowIndex] = array_values($row); + } } } - if ($allRows && count($rows) > 1) { - $out = Formatter::format2DAligned($rows, $this->precision); - return $start . ((($label !== null) ? ($label . "\n" . $out) : $out)) . $end; - } + + $args[$i] = $array; + $this->labels[$i] = (new \ReflectionClass($value))->getShortName(); } - return null; + + if (count($args) > self::MAX_ARGS) { + $args = array_slice($args, 0, self::MAX_ARGS); + } + return $args; } /** @@ -362,7 +298,7 @@ private function tryMultiple1DRows(array $args, string $start, string $end): ?st private function formatDefaultParts(array $args, array $fmt): array { $parts = []; - foreach ($args as $arg) { + foreach ($args as $i => $arg) { if (is_array($arg)) { if (Validator::is3D($arg)) { $parts[] = Formatter::format3DTorch( @@ -373,7 +309,7 @@ private function formatDefaultParts(array $args, array $fmt): array (int)($fmt['tailRows'] ?? 5), (int)($fmt['headCols'] ?? 5), (int)($fmt['tailCols'] ?? 5), - (string)($fmt['label'] ?? 'tensor'), + (string)($fmt['label'] ?? ($this->labels[$i] ?? 'tensor')), $this->precision ); } elseif (Validator::is2D($arg)) { @@ -383,7 +319,7 @@ private function formatDefaultParts(array $args, array $fmt): array (int)($fmt['tailRows'] ?? 5), (int)($fmt['headCols'] ?? 5), (int)($fmt['tailCols'] ?? 5), - (string)($fmt['label'] ?? 'tensor'), + (string)($fmt['label'] ?? ($this->labels[$i] ?? 'tensor')), $this->precision ); } else { diff --git a/src/functions.php b/src/functions.php index 10cd2f2..98d5e2b 100644 --- a/src/functions.php +++ b/src/functions.php @@ -46,3 +46,45 @@ function ppd(...$args) $exiter(); } +/** + * Print a difference matrix between two arrays. + * + * For each corresponding element, prints the same value when equal, + * or '-' when different or missing. + * + * @param array $a + * @param array $b + * @return string + */ +function pdiff(array $a, array $b): string +{ + $rows = max(count($a), count($b)); + $diff = []; + + for ($i = 0; $i < $rows; $i++) { + $rowA = $a[$i] ?? []; + $rowB = $b[$i] ?? []; + + // Handle scalar-vs-scalar rows directly + if (!is_array($rowA) && !is_array($rowB)) { + $diff[] = [$rowA === $rowB ? $rowA : '-']; + continue; + } + + // Normalize to row arrays + $rowA = is_array($rowA) ? $rowA : [$rowA]; + $rowB = is_array($rowB) ? $rowB : [$rowB]; + + $cols = max(count($rowA), count($rowB)); + $rowDiff = []; + for ($j = 0; $j < $cols; $j++) { + $vA = $rowA[$j] ?? null; + $vB = $rowB[$j] ?? null; + $rowDiff[] = ($vA === $vB) ? $vA : '-'; + } + $diff[] = $rowDiff; + } + + return pprint($diff); +} + diff --git a/tests/FormatterTest.php b/tests/FormatterTest.php index c4304a8..61c129a 100644 --- a/tests/FormatterTest.php +++ b/tests/FormatterTest.php @@ -204,7 +204,7 @@ public static function format3DTorchProvider(): array 5, 5, 5, 5, 5, 5, 'tensor', 2, - "tensor([\n [[1, 2],\n [3, 4]],\n\n [[5, 6],\n [7, 8]]\n])", + "tensor([\n [[1, 2],\n [3, 4]],\n [[5, 6],\n [7, 8]]\n])", ], 'block ellipsis with inner 2D ellipses' => [ [ @@ -215,7 +215,7 @@ public static function format3DTorchProvider(): array 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])", + "tensor([\n [[1, ..., 3],\n ...,\n [7, ..., 9]],\n ...,\n [[1, ..., 3],\n ...,\n [7, ..., 9]]\n])", ], ]; } diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index ee89131..f8965ce 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -11,12 +11,13 @@ use PHPUnit\Framework\Attributes\CoversFunction; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\Attributes\PreserveGlobalState; -use function Apphp\PrettyPrint\{pprint, pp, ppd}; +use function Apphp\PrettyPrint\{pprint, pp, ppd, pdiff}; #[Group('PrettyPrint')] #[CoversFunction('Apphp\\PrettyPrint\\pprint')] #[CoversFunction('Apphp\\PrettyPrint\\pp')] #[CoversFunction('Apphp\\PrettyPrint\\ppd')] +#[CoversFunction('Apphp\\PrettyPrint\\pdiff')] final class FunctionsTest extends TestCase { #[Test] @@ -84,4 +85,27 @@ public function toArray(): array self::assertSame("[1, 2]\n", $out); } + + #[Test] + #[TestDox('pdiff prints a matrix with identical values or x on mismatches')] + public function pdiffPrintsDifferenceMatrix(): void + { + $a = [ + [1, 2, 3], + [4, 5, 6], + ]; + $b = [ + [1, 9, 3], + [0, 5, 7], + ]; + + ob_start(); + pdiff($a, $b); + $out = ob_get_clean(); + + // Expect 1st row: [1, x, 3] + self::assertMatchesRegularExpression('/\[\s*1,\s*-,\s*3\s*\]/', $out); + // Expect 2nd row: [x, 5, x] + self::assertMatchesRegularExpression('/\[\s*-,\s*5,\s*-\s*\]/', $out); + } } diff --git a/tests/PrettyPrintTest.php b/tests/PrettyPrintTest.php index c5f3acb..a04815f 100644 --- a/tests/PrettyPrintTest.php +++ b/tests/PrettyPrintTest.php @@ -108,7 +108,7 @@ public function alignedRows1D(): void ob_start(); $pp([1, 23, 456], [12, 3, 45]); $out = ob_get_clean(); - $expected = "[[ 1, 23, 456],\n [12, 3, 45]]\n"; + $expected = "[1, 23, 456] [12, 3, 45]\n"; self::assertSame($expected, $out); } @@ -121,7 +121,7 @@ public function labelPlus2DMatrix(): void ob_start(); $pp('Confusion matrix:', $matrix); $out = ob_get_clean(); - $expected = "Confusion matrix:\n[[ 1, 23],\n [456, 7]]\n"; + $expected = "Confusion matrix: tensor([\n [ 1, 23],\n [456, 7]\n])\n"; self::assertSame($expected, $out); } @@ -161,8 +161,6 @@ public function tensor3DFormattingSmall(): void // Should contain two 2D blocks formatted; allow padding spaces self::assertMatchesRegularExpression('/\[\s*1,\s*2\s*\]/', $out); self::assertMatchesRegularExpression('/\[\s*7,\s*8\s*\]/', $out); - // Blocks are separated by a blank line in between (",\n\n ") - self::assertStringContainsString("\n\n ", $out); } #[Test] @@ -244,8 +242,7 @@ public function labelPlus3DTensor(): void ob_start(); $pp('Tensor:', $tensor); $out = ob_get_clean(); - self::assertStringStartsWith('tensor([', $out); - self::assertStringNotContainsString('Tensor:', $out); + self::assertStringStartsWith('Tensor: tensor([', $out); self::assertStringContainsString('])', $out); } @@ -374,7 +371,7 @@ public function multipleRowsWithLabel(): void ob_start(); $pp('Label', [1, 2], [3, 4]); $out = ob_get_clean(); - self::assertSame("Label\n[[1, 2],\n [3, 4]]\n", $out); + self::assertSame("Label [1, 2] [3, 4]\n", $out); } #[Test] @@ -554,4 +551,52 @@ public function toArray(): array self::assertSame("[5, 6]\n", $out); } + + #[Test] + #[TestDox('ignores objects whose asArray() does not return an array')] + public function ignoresObjectWhenAsArrayDoesNotReturnArray(): void + { + $pp = new PrettyPrint(); + + $obj = new class { + public function asArray(): string + { + return 'not-an-array'; + } + }; + + ob_start(); + $pp($obj); + $out = ob_get_clean(); + + // Falls back to generic object formatting path + self::assertSame("Object\n", $out); + } + + #[Test] + #[TestDox('normalizes associative rows from asArray() into indexed arrays for 2D formatting')] + public function normalizesAssociativeRowsFromAsArray(): void + { + $pp = new PrettyPrint(); + + $obj = new class { + public function asArray(): array + { + return [ + ['id' => 1, 'name' => 'Alice'], + ['id' => 2, 'name' => 'Bob'], + ]; + } + }; + + ob_start(); + $pp($obj, label: 'Users'); + $out = ob_get_clean(); + + // Ensure values are visible (rows have been normalized with array_values) + self::assertStringContainsString('Alice', $out); + self::assertStringContainsString('Bob', $out); + // And we no longer see completely empty cells like "[, ]" + self::assertStringNotContainsString('[, ]', $out); + } }