aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--challenge-347/lubos-kolouch/README32
-rw-r--r--challenge-347/lubos-kolouch/perl/ch-1.pl112
-rw-r--r--challenge-347/lubos-kolouch/perl/ch-2.pl113
-rw-r--r--challenge-347/lubos-kolouch/python/ch-1.py100
-rw-r--r--challenge-347/lubos-kolouch/python/ch-2.py71
-rw-r--r--challenge-348/lubos-kolouch/README32
-rw-r--r--challenge-348/lubos-kolouch/perl/ch-1.pl92
-rw-r--r--challenge-348/lubos-kolouch/perl/ch-2.pl105
-rw-r--r--challenge-348/lubos-kolouch/python/ch-1.py63
-rw-r--r--challenge-348/lubos-kolouch/python/ch-2.py85
10 files changed, 773 insertions, 32 deletions
diff --git a/challenge-347/lubos-kolouch/README b/challenge-347/lubos-kolouch/README
index 7f47843cb2..8b814b0ccb 100644
--- a/challenge-347/lubos-kolouch/README
+++ b/challenge-347/lubos-kolouch/README
@@ -1,26 +1,26 @@
-# Perl Weekly Challenge 346 Solutions
+# Perl Weekly Challenge 347 Solutions
-Perl and Python implementations for both tasks of Weekly Challenge 346.
+Perl and Python implementations for both tasks of Weekly Challenge 347.
-## Task 1: Longest Parenthesis
+## Task 1: Format Date
-Determine the length of the longest valid parenthesis substring contained
-within an input built from `(` and `)`.
+Convert strings like `10th Nov 2025` into ISO form `2025-11-10` while validating
+the provided day, month, and year values.
-- **Perl (`perl/ch-1.pl`)**: Uses a stack of indices with type-checked input
- to track unmatched parentheses and compute the maximum span.
-- **Python (`python/ch-1.py`)**: Mirrors the stack-based pass with explicit
- type hints and `unittest` coverage for the specification data.
+- **Perl (`perl/ch-1.pl`)**: Parses the ordinal components with type-checked
+ input and enforces suffix/month/year constraints before formatting.
+- **Python (`python/ch-1.py`)**: Uses the same parsing logic with type
+ annotations and `unittest` coverage for each provided example.
-## Task 2: Magic Expression
+## Task 2: Format Phone Number
-Insert `+`, `-`, and `*` between the digits of the supplied string so the
-expression evaluates to the target integer.
+Normalize phone numbers that contain digits, spaces, and dashes by removing
+separators and regrouping the digits according to the challenge rules.
-- **Perl (`perl/ch-2.pl`)**: Performs depth-first search with precedence-aware
- bookkeeping, returning every lexicographically sorted solution.
-- **Python (`python/ch-2.py`)**: Applies the same backtracking strategy with
- typed helpers and unit tests bound to the official examples.
+- **Perl (`perl/ch-2.pl`)**: Validates allowed characters, strips separators,
+ and creates 3-digit blocks with special handling for the trailing digits.
+- **Python (`python/ch-2.py`)**: Mirrors the greedy regrouping approach and
+ includes type hints plus example-driven unit tests.
## Running the Solutions
diff --git a/challenge-347/lubos-kolouch/perl/ch-1.pl b/challenge-347/lubos-kolouch/perl/ch-1.pl
new file mode 100644
index 0000000000..13d82e2e15
--- /dev/null
+++ b/challenge-347/lubos-kolouch/perl/ch-1.pl
@@ -0,0 +1,112 @@
+#!/usr/bin/env perl
+use v5.38;
+use warnings;
+use feature 'signatures';
+no warnings 'experimental::signatures';
+## no critic (Subroutines::ProhibitSubroutinePrototypes)
+
+use Type::Params qw(compile);
+use Types::Standard qw(Str);
+
+=pod
+
+=head1 NAME
+
+ch-1.pl - Format Date (Perl Weekly Challenge 347)
+
+=head1 SYNOPSIS
+
+ perl ch-1.pl "1st Jan 2025"
+ perl ch-1.pl # executes the example tests
+
+=head1 DESCRIPTION
+
+Parses an input such as C<10th Nov 2025> and emits it in ISO form
+C<2025-11-10>. Validation follows the specification-provided sets of days,
+months, and years.
+
+=cut
+
+my %MONTH_INDEX = (
+ Jan => 1,
+ Feb => 2,
+ Mar => 3,
+ Apr => 4,
+ May => 5,
+ Jun => 6,
+ Jul => 7,
+ Aug => 8,
+ Sep => 9,
+ Oct => 10,
+ Nov => 11,
+ Dec => 12,
+);
+
+sub format_date ($input) {
+ state $check = compile(Str);
+ ($input) = $check->($input);
+ $input =~ s/^\s+|\s+$//g;
+
+ my ( $day, $suffix, $month_name, $year ) =
+ $input =~ /\A(\d{1,2})(st|nd|rd|th)\s+([A-Za-z]{3})\s+(\d{4})\z/
+ or die 'Invalid date format';
+
+ my $day_num = $day + 0;
+ die 'Day out of range' unless 1 <= $day_num && $day_num <= 31;
+ die 'Invalid ordinal suffix' unless _suffix_for($day_num) eq $suffix;
+
+ my $month_num = $MONTH_INDEX{$month_name} // die 'Unknown month abbreviation';
+
+ my $year_num = $year + 0;
+ die 'Year out of range' unless 1900 <= $year_num && $year_num <= 2100;
+
+ return sprintf '%04d-%02d-%02d', $year_num, $month_num, $day_num;
+}
+
+sub _suffix_for ($day) {
+ return 'th' if $day % 100 >= 11 && $day % 100 <= 13;
+ return ( 'th', 'st', 'nd', 'rd', 'th', 'th', 'th', 'th', 'th', 'th' )[ $day % 10 ];
+}
+
+sub _run_cli (@args) {
+ if ( !@args ) {
+ _run_tests();
+ return;
+ }
+
+ die qq{Usage: perl $0 "1st Jan 2025"\n} if @args != 1;
+ my $input = $args[0];
+ say qq{Input: \$str = "$input"};
+ my $formatted = format_date($input);
+ say qq{Output: "$formatted"};
+}
+
+sub _run_tests {
+ require Test::More;
+ Test::More->import();
+
+ my @cases = (
+ { label => 'Example 1', str => '1st Jan 2025', expected => '2025-01-01' },
+ { label => 'Example 2', str => '22nd Feb 2025', expected => '2025-02-22' },
+ { label => 'Example 3', str => '15th Apr 2025', expected => '2025-04-15' },
+ { label => 'Example 4', str => '23rd Oct 2025', expected => '2025-10-23' },
+ { label => 'Example 5', str => '31st Dec 2025', expected => '2025-12-31' },
+ );
+
+ Test::More::plan( tests => scalar @cases );
+ for my $case (@cases) {
+ my $got = format_date( $case->{str} );
+ Test::More::is( $got, $case->{expected}, $case->{label} );
+ }
+}
+
+_run_cli(@ARGV);
+
+=head1 FUNCTIONS
+
+=head2 format_date($input)
+
+Returns the ISO-formatted representation of C<$input>. Throws an exception when
+the supplied string falls outside the allowed day, month, or year sets.
+
+=cut
diff --git a/challenge-347/lubos-kolouch/perl/ch-2.pl b/challenge-347/lubos-kolouch/perl/ch-2.pl
new file mode 100644
index 0000000000..d936af0d77
--- /dev/null
+++ b/challenge-347/lubos-kolouch/perl/ch-2.pl
@@ -0,0 +1,113 @@
+#!/usr/bin/env perl
+use v5.38;
+use warnings;
+use feature 'signatures';
+no warnings 'experimental::signatures';
+## no critic (Subroutines::ProhibitSubroutinePrototypes)
+
+use Type::Params qw(compile);
+use Types::Standard qw(Str);
+
+=pod
+
+=head1 NAME
+
+ch-2.pl - Format Phone Number (Perl Weekly Challenge 347)
+
+=head1 SYNOPSIS
+
+ perl ch-2.pl "12 345-6789"
+ perl ch-2.pl # runs the embedded tests
+
+=head1 DESCRIPTION
+
+Normalizes a phone number consisting of digits, spaces, and dashes into blocks
+separated by single dashes according to the rules from the challenge:
+
+=over 4
+
+=item *
+Remove all spaces and dashes.
+
+=item *
+Group from the left using blocks of length 3.
+
+=item *
+If the final chunk would contain a single digit, rebalance the last two blocks
+into two groups of length 2.
+
+=back
+
+=cut
+
+sub format_phone_number ($input) {
+ state $check = compile(Str);
+ ($input) = $check->($input);
+ die 'Phone number requires at least two digits' unless $input =~ /\d.*\d/;
+ die 'Invalid characters in phone number'
+ if $input =~ /[^0-9\s-]/;
+
+ my $digits = $input =~ s/[\s-]//gr;
+ my $length = length $digits;
+ die 'Phone number requires at least two digits' if $length < 2;
+
+ my @blocks;
+ my $pos = 0;
+ while ( $length - $pos > 4 ) {
+ push @blocks, substr $digits, $pos, 3;
+ $pos += 3;
+ }
+
+ my $remaining = substr $digits, $pos;
+ if ( length $remaining == 4 ) {
+ push @blocks, substr( $remaining, 0, 2 ), substr( $remaining, 2, 2 );
+ }
+ elsif ( length $remaining ) {
+ push @blocks, $remaining;
+ }
+
+ return join '-', @blocks;
+}
+
+sub _run_cli (@args) {
+ if ( !@args ) {
+ _run_tests();
+ return;
+ }
+
+ die qq{Usage: perl $0 "<phone number>"\n} if @args != 1;
+ my $input = $args[0];
+ say qq{Input: \$phone = "$input"};
+ my $formatted = format_phone_number($input);
+ say qq{Output: "$formatted"};
+}
+
+sub _run_tests {
+ require Test::More;
+ Test::More->import();
+
+ my @cases = (
+ { label => 'Example 1', phone => '1-23-45-6', expected => '123-456' },
+ { label => 'Example 2', phone => '1234', expected => '12-34' },
+ { label => 'Example 3', phone => '12 345-6789', expected => '123-456-789' },
+ { label => 'Example 4', phone => '123 4567', expected => '123-45-67' },
+ { label => 'Example 5', phone => '123 456-78', expected => '123-456-78' },
+ );
+
+ Test::More::plan( tests => scalar @cases );
+ for my $case (@cases) {
+ my $got = format_phone_number( $case->{phone} );
+ Test::More::is( $got, $case->{expected}, $case->{label} );
+ }
+}
+
+_run_cli(@ARGV);
+
+=head1 FUNCTIONS
+
+=head2 format_phone_number($input)
+
+Returns the normalized representation of C<$input>. Throws an exception unless
+the input contains only digits, spaces, and dashes and at least two digits.
+
+=cut
diff --git a/challenge-347/lubos-kolouch/python/ch-1.py b/challenge-347/lubos-kolouch/python/ch-1.py
new file mode 100644
index 0000000000..62423b7a3a
--- /dev/null
+++ b/challenge-347/lubos-kolouch/python/ch-1.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+"""Format Date - Perl Weekly Challenge 347 task 1."""
+
+from __future__ import annotations
+
+from typing import Final
+from collections.abc import Sequence
+import re
+import sys
+import unittest
+
+MONTHS: Final[dict[str, int]] = {
+ "Jan": 1,
+ "Feb": 2,
+ "Mar": 3,
+ "Apr": 4,
+ "May": 5,
+ "Jun": 6,
+ "Jul": 7,
+ "Aug": 8,
+ "Sep": 9,
+ "Oct": 10,
+ "Nov": 11,
+ "Dec": 12,
+}
+
+DATE_PATTERN: Final[re.Pattern[str]] = re.compile(
+ r"^(?P<day>\d{1,2})(?P<suffix>st|nd|rd|th)\s+(?P<month>[A-Za-z]{3})\s+(?P<year>\d{4})$"
+)
+
+
+def _suffix_for(day: int) -> str:
+ if 11 <= day % 100 <= 13:
+ return "th"
+ suffixes = ["th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th"]
+ return suffixes[day % 10]
+
+
+def format_date(text: str) -> str:
+ """Convert strings like '10th Nov 2025' to '2025-11-10'."""
+ match = DATE_PATTERN.match(text.strip())
+ if not match:
+ raise ValueError("Invalid date format")
+
+ day = int(match.group("day"))
+ suffix = match.group("suffix")
+ month_name = match.group("month")
+ year = int(match.group("year"))
+
+ if not 1 <= day <= 31:
+ raise ValueError("Day out of range")
+ if _suffix_for(day) != suffix:
+ raise ValueError("Invalid ordinal suffix")
+
+ month = MONTHS.get(month_name)
+ if month is None:
+ raise ValueError("Unknown month abbreviation")
+ if not 1900 <= year <= 2100:
+ raise ValueError("Year out of range")
+
+ return f"{year:04d}-{month:02d}-{day:02d}"
+
+
+class FormatDateExamples(unittest.TestCase):
+ """Specification-provided examples."""
+
+ def test_example_1(self) -> None:
+ self.assertEqual(format_date("1st Jan 2025"), "2025-01-01")
+
+ def test_example_2(self) -> None:
+ self.assertEqual(format_date("22nd Feb 2025"), "2025-02-22")
+
+ def test_example_3(self) -> None:
+ self.assertEqual(format_date("15th Apr 2025"), "2025-04-15")
+
+ def test_example_4(self) -> None:
+ self.assertEqual(format_date("23rd Oct 2025"), "2025-10-23")
+
+ def test_example_5(self) -> None:
+ self.assertEqual(format_date("31st Dec 2025"), "2025-12-31")
+
+
+def main(argv: Sequence[str] | None = None) -> None:
+ """Command-line interface: run tests or format a provided string."""
+ args = list(sys.argv[1:] if argv is None else argv)
+ if not args:
+ unittest.main(argv=[sys.argv[0]])
+ return
+
+ if len(args) != 1:
+ raise SystemExit('Usage: python3 ch-1.py "1st Jan 2025"')
+
+ value = args[0]
+ print(f'Input: $str = "{value}"')
+ result = format_date(value)
+ print(f'Output: "{result}"')
+
+
+if __name__ == "__main__":
+ main()
diff --git a/challenge-347/lubos-kolouch/python/ch-2.py b/challenge-347/lubos-kolouch/python/ch-2.py
new file mode 100644
index 0000000000..8a96a2712b
--- /dev/null
+++ b/challenge-347/lubos-kolouch/python/ch-2.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""Format Phone Number - Perl Weekly Challenge 347 task 2."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+import sys
+import unittest
+
+
+def format_phone_number(text: str) -> str:
+ """Group phone digits from the left using 3-digit blocks with special tail handling."""
+ if any(ch for ch in text if ch not in "0123456789 -"):
+ raise ValueError("Invalid characters in phone number")
+
+ digits = "".join(ch for ch in text if ch.isdigit())
+ if len(digits) < 2:
+ raise ValueError("Phone number requires at least two digits")
+
+ blocks: list[str] = []
+ idx = 0
+ while len(digits) - idx > 4:
+ blocks.append(digits[idx:idx + 3])
+ idx += 3
+
+ tail = digits[idx:]
+ if len(tail) == 4:
+ blocks.extend([tail[:2], tail[2:]])
+ elif tail:
+ blocks.append(tail)
+
+ return "-".join(blocks)
+
+
+class FormatPhoneNumberExamples(unittest.TestCase):
+ """Specification-provided examples."""
+
+ def test_example_1(self) -> None:
+ self.assertEqual(format_phone_number("1-23-45-6"), "123-456")
+
+ def test_example_2(self) -> None:
+ self.assertEqual(format_phone_number("1234"), "12-34")
+
+ def test_example_3(self) -> None:
+ self.assertEqual(format_phone_number("12 345-6789"), "123-456-789")
+
+ def test_example_4(self) -> None:
+ self.assertEqual(format_phone_number("123 4567"), "123-45-67")
+
+ def test_example_5(self) -> None:
+ self.assertEqual(format_phone_number("123 456-78"), "123-456-78")
+
+
+def main(argv: Sequence[str] | None = None) -> None:
+ """Command-line interface."""
+ args = list(sys.argv[1:] if argv is None else argv)
+ if not args:
+ unittest.main(argv=[sys.argv[0]])
+ return
+
+ if len(args) != 1:
+ raise SystemExit('Usage: python3 ch-2.py "12 345-6789"')
+
+ phone = args[0]
+ print(f'Input: $phone = "{phone}"')
+ formatted = format_phone_number(phone)
+ print(f'Output: "{formatted}"')
+
+
+if __name__ == "__main__":
+ main()
diff --git a/challenge-348/lubos-kolouch/README b/challenge-348/lubos-kolouch/README
index 7f47843cb2..e48d8e40b9 100644
--- a/challenge-348/lubos-kolouch/README
+++ b/challenge-348/lubos-kolouch/README
@@ -1,26 +1,26 @@
-# Perl Weekly Challenge 346 Solutions
+# Perl Weekly Challenge 348 Solutions
-Perl and Python implementations for both tasks of Weekly Challenge 346.
+Perl and Python implementations for both tasks of Weekly Challenge 348.
-## Task 1: Longest Parenthesis
+## Task 1: String Alike
-Determine the length of the longest valid parenthesis substring contained
-within an input built from `(` and `)`.
+Split an even-length string into two halves and verify that each half contains
+the same, non-zero number of vowels.
-- **Perl (`perl/ch-1.pl`)**: Uses a stack of indices with type-checked input
- to track unmatched parentheses and compute the maximum span.
-- **Python (`python/ch-1.py`)**: Mirrors the stack-based pass with explicit
- type hints and `unittest` coverage for the specification data.
+- **Perl (`perl/ch-1.pl`)**: Uses typed parameter validation and a single pass
+ over each half to count vowels before comparing the totals.
+- **Python (`python/ch-1.py`)**: Provides the same logic with type annotations
+ and `unittest` coverage of the specification examples.
-## Task 2: Magic Expression
+## Task 2: Convert Time
-Insert `+`, `-`, and `*` between the digits of the supplied string so the
-expression evaluates to the target integer.
+Determine the minimal number of operations required to turn the source 24-hour
+timestamp into the target when you may add 1, 5, 15, or 60 minutes.
-- **Perl (`perl/ch-2.pl`)**: Performs depth-first search with precedence-aware
- bookkeeping, returning every lexicographically sorted solution.
-- **Python (`python/ch-2.py`)**: Applies the same backtracking strategy with
- typed helpers and unit tests bound to the official examples.
+- **Perl (`perl/ch-2.pl`)**: Converts stamps to minutes-past-midnight with type
+ validation and applies a greedy decomposition over the permitted steps.
+- **Python (`python/ch-2.py`)**: Uses the same helper logic, fully typed, along
+ with a unittest suite that exercises the provided scenarios.
## Running the Solutions
diff --git a/challenge-348/lubos-kolouch/perl/ch-1.pl b/challenge-348/lubos-kolouch/perl/ch-1.pl
new file mode 100644
index 0000000000..0d2975ab92
--- /dev/null
+++ b/challenge-348/lubos-kolouch/perl/ch-1.pl
@@ -0,0 +1,92 @@
+#!/usr/bin/env perl
+use v5.38;
+use warnings;
+use feature 'signatures';
+no warnings 'experimental::signatures';
+## no critic (Subroutines::ProhibitSubroutinePrototypes)
+
+use Type::Params qw(compile);
+use Types::Standard qw(Str);
+
+=pod
+
+=head1 NAME
+
+ch-1.pl - String Alike
+
+=head1 SYNOPSIS
+
+ perl ch-1.pl <even_length_string>
+ perl ch-1.pl # runs the embedded tests
+
+=head1 DESCRIPTION
+
+Checks whether a provided even-length string can be split into two halves that
+share the same non-zero number of vowels. The implementation keeps the logic in
+L</string_alike> so it may be reused both by the command line interface and the
+unit tests bundled at the end of the file.
+
+=cut
+
+my $STRING_CHECK = compile(Str);
+
+sub string_alike ($text) {
+ ($text) = $STRING_CHECK->($text);
+ my $length = length $text;
+ die 'Expected an even-length string' if $length % 2;
+
+ my $half = $length / 2;
+ my $first_half = substr $text, 0, $half;
+ my $second_half = substr $text, $half;
+ my $first_count = ( $first_half =~ tr/AEIOUaeiou// );
+ my $second_count = ( $second_half =~ tr/AEIOUaeiou// );
+
+ return $first_count > 0 && $first_count == $second_count;
+}
+
+sub _run_cli (@args) {
+ if ( !@args ) {
+ _run_tests();
+ return;
+ }
+
+ die "Usage: perl $0 <even_length_string>\n" if @args != 1;
+ my $input = $args[0];
+ say qq{Input: \$str = "$input"};
+ my $result = string_alike($input) ? 'true' : 'false';
+ say "Output: $result";
+}
+
+sub _run_tests {
+ require Test::More;
+ Test::More->import();
+
+ my @cases = (
+ { label => 'Example 1', str => 'textbook', expected => 0 },
+ { label => 'Example 2', str => 'book', expected => 1 },
+ { label => 'Example 3', str => 'AbCdEfGh', expected => 1 },
+ { label => 'Example 4', str => 'rhythmmyth', expected => 0 },
+ { label => 'Example 5', str => 'UmpireeAudio', expected => 0 },
+ );
+
+ Test::More::plan( tests => scalar @cases );
+
+ for my $case (@cases) {
+ my $got = string_alike( $case->{str} ) ? 1 : 0;
+ Test::More::is( $got, $case->{expected}, $case->{label} );
+ }
+}
+
+_run_cli(@ARGV);
+
+=pod
+
+=head1 FUNCTIONS
+
+=head2 string_alike($text)
+
+Returns a boolean indicating whether the halves of C<$text> have the same
+positive count of vowels. Throws an exception when the input does not meet the
+problem specification (e.g. odd length).
+
+=cut
diff --git a/challenge-348/lubos-kolouch/perl/ch-2.pl b/challenge-348/lubos-kolouch/perl/ch-2.pl
new file mode 100644
index 0000000000..463cbe7244
--- /dev/null
+++ b/challenge-348/lubos-kolouch/perl/ch-2.pl
@@ -0,0 +1,105 @@
+#!/usr/bin/env perl
+use v5.38;
+use warnings;
+use feature 'signatures';
+no warnings 'experimental::signatures';
+## no critic (Subroutines::ProhibitSubroutinePrototypes)
+
+use Type::Params qw(compile);
+use Types::Standard qw(StrMatch);
+
+=pod
+
+=head1 NAME
+
+ch-2.pl - Convert Time
+
+=head1 SYNOPSIS
+
+ perl ch-2.pl <source> <target>
+ perl ch-2.pl # runs the embedded tests
+
+=head1 DESCRIPTION
+
+Computes the minimal number of allowed operations needed to transform a source
+24-hour clock stamp into a target one by adding 1, 5, 15, or 60 minutes. The
+greedy strategy works because every duration divides the next larger one.
+
+=cut
+
+my $STAMP_CHECK = compile( StrMatch [qr/\A\d\d:\d\d\z/] );
+my @OPERATIONS = ( 60, 15, 5, 1 );
+
+sub convert_time ( $source, $target ) {
+ state $pair_check = compile( StrMatch [qr/\A\d\d:\d\d\z/], StrMatch [qr/\A\d\d:\d\d\z/], );
+ ( $source, $target ) = $pair_check->( $source, $target );
+
+ my $start = _minutes_from_midnight($source);
+ my $end = _minutes_from_midnight($target);
+ my $diff = $end - $start;
+ $diff += 24 * 60 if $diff < 0;
+
+ my $operations = 0;
+ for my $step (@OPERATIONS) {
+ $operations += int( $diff / $step );
+ $diff %= $step;
+ }
+
+ return $operations;
+}
+
+sub _minutes_from_midnight ($stamp) {
+ ($stamp) = $STAMP_CHECK->($stamp);
+ my ( $hour, $minute ) = split /:/, $stamp, 2;
+ die 'Invalid hour component' if $hour < 0 || $hour > 23;
+ die 'Invalid minute component' if $minute < 0 || $minute > 59;
+ return ( $hour * 60 ) + $minute;
+}
+
+sub _run_cli (@args) {
+ if ( !@args ) {
+ _run_tests();
+ return;
+ }
+
+ die "Usage: perl $0 <source> <target>\n" if @args != 2;
+ my ( $source, $target ) = @args;
+ say qq{Input: \$source = "$source"};
+ say qq{ \$target = "$target"};
+ my $steps = convert_time( $source, $target );
+ say "Output: $steps";
+}
+
+sub _run_tests {
+ require Test::More;
+ Test::More->import();
+
+ my @cases = (
+ { label => 'Example 1', source => '02:30', target => '02:45', expected => 1 },
+ { label => 'Example 2', source => '11:55', target => '12:15', expected => 2 },
+ { label => 'Example 3', source => '09:00', target => '13:00', expected => 4 },
+ { label => 'Example 4', source => '23:45', target => '00:30', expected => 3 },
+ { label => 'Example 5', source => '14:20', target => '15:25', expected => 2 },
+ );
+
+ Test::More::plan( tests => scalar @cases );
+
+ for my $case (@cases) {
+ my $got = convert_time( $case->{source}, $case->{target} );
+ Test::More::is( $got, $case->{expected}, $case->{label} );
+ }
+}
+
+_run_cli(@ARGV);
+
+=pod
+
+=head1 FUNCTIONS
+
+=head2 convert_time($source, $target)
+
+Returns the minimal number of permitted increments required to reach C<$target>
+from C<$source>. Internally converts both timestamps to minutes past midnight
+and applies a greedy decomposition.
+
+=cut
diff --git a/challenge-348/lubos-kolouch/python/ch-1.py b/challenge-348/lubos-kolouch/python/ch-1.py
new file mode 100644
index 0000000000..3a878a835a
--- /dev/null
+++ b/challenge-348/lubos-kolouch/python/ch-1.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""String Alike - Perl Weekly Challenge 348 task 1."""
+
+from __future__ import annotations
+
+from typing import Final
+from collections.abc import Sequence
+import sys
+import unittest
+
+VOWELS: Final[frozenset[str]] = frozenset("aeiouAEIOU")
+
+
+def is_string_alike(text: str) -> bool:
+ """Return True when the halves of ``text`` share the same positive vowel count."""
+ if len(text) % 2 != 0:
+ raise ValueError("Expected an even-length string")
+
+ half = len(text) // 2
+ left = text[:half]
+ right = text[half:]
+ left_count = sum(1 for char in left if char in VOWELS)
+ right_count = sum(1 for char in right if char in VOWELS)
+ return left_count > 0 and left_count == right_count
+
+
+class StringAlikeExamples(unittest.TestCase):
+ """Example-based tests provided in the specification."""
+
+ def test_example_1(self) -> None:
+ self.assertFalse(is_string_alike("textbook"))
+
+ def test_example_2(self) -> None:
+ self.assertTrue(is_string_alike("book"))
+
+ def test_example_3(self) -> None:
+ self.assertTrue(is_string_alike("AbCdEfGh"))
+
+ def test_example_4(self) -> None:
+ self.assertFalse(is_string_alike("rhythmmyth"))
+
+ def test_example_5(self) -> None:
+ self.assertFalse(is_string_alike("UmpireeAudio"))
+
+
+def main(argv: Sequence[str] | None = None) -> None:
+ """Command-line launcher."""
+ args = list(sys.argv[1:] if argv is None else argv)
+ if not args:
+ unittest.main(argv=[sys.argv[0]])
+ return
+
+ if len(args) != 1:
+ raise SystemExit("Usage: python3 ch-1.py <even_length_string>")
+
+ word = args[0]
+ print(f'Input: $str = "{word}"')
+ result = "true" if is_string_alike(word) else "false"
+ print(f"Output: {result}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/challenge-348/lubos-kolouch/python/ch-2.py b/challenge-348/lubos-kolouch/python/ch-2.py
new file mode 100644
index 0000000000..ab0105c2de
--- /dev/null
+++ b/challenge-348/lubos-kolouch/python/ch-2.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+"""Convert Time - Perl Weekly Challenge 348 task 2."""
+
+from __future__ import annotations
+
+from typing import Final
+from collections.abc import Sequence
+import sys
+import unittest
+
+MINUTES_PER_DAY: Final[int] = 24 * 60
+ALLOWED_STEPS: Final[tuple[int, ...]] = (60, 15, 5, 1)
+
+
+def minutes_from_midnight(stamp: str) -> int:
+ """Convert a HH:MM timestamp into total minutes past midnight."""
+ if len(stamp) != 5 or stamp[2] != ":":
+ raise ValueError("Time must be in HH:MM format")
+
+ hours_str, minutes_str = stamp.split(":", 1)
+ if not (hours_str.isdigit() and minutes_str.isdigit()):
+ raise ValueError("Time must only contain digits")
+
+ hours = int(hours_str)
+ minutes = int(minutes_str)
+ if not (0 <= hours <= 23 and 0 <= minutes <= 59):
+ raise ValueError("Time components out of range")
+
+ return hours * 60 + minutes
+
+
+def convert_time(source: str, target: str) -> int:
+ """Return the minimal number of operations to transform source into target."""
+ start = minutes_from_midnight(source)
+ end = minutes_from_midnight(target)
+ diff = end - start
+ if diff < 0:
+ diff += MINUTES_PER_DAY
+
+ operations = 0
+ remaining = diff
+ for step in ALLOWED_STEPS:
+ operations += remaining // step
+ remaining %= step
+ return operations
+
+
+class ConvertTimeExamples(unittest.TestCase):
+ """Example-based tests for the specification."""
+
+ def test_example_1(self) -> None:
+ self.assertEqual(convert_time("02:30", "02:45"), 1)
+
+ def test_example_2(self) -> None:
+ self.assertEqual(convert_time("11:55", "12:15"), 2)
+
+ def test_example_3(self) -> None:
+ self.assertEqual(convert_time("09:00", "13:00"), 4)
+
+ def test_example_4(self) -> None:
+ self.assertEqual(convert_time("23:45", "00:30"), 3)
+
+ def test_example_5(self) -> None:
+ self.assertEqual(convert_time("14:20", "15:25"), 2)
+
+
+def main(argv: Sequence[str] | None = None) -> None:
+ """Command-line launcher."""
+ args = list(sys.argv[1:] if argv is None else argv)
+ if not args:
+ unittest.main(argv=[sys.argv[0]])
+ return
+
+ if len(args) != 2:
+ raise SystemExit("Usage: python3 ch-2.py <source> <target>")
+
+ source, target = args
+ print(f'Input: $source = "{source}"')
+ print(f' $target = "{target}"')
+ steps = convert_time(source, target)
+ print(f"Output: {steps}")
+
+
+if __name__ == "__main__":
+ main()