Repository: python-babel/babel

Issue:
Obsoletes with same ID from different contexts get removed on update
## Overview Description

When a file contains multiple obsoletes _from different contexts_, and they have the same ID, all but the last one will get stripped from the file.

## Steps to Reproduce

1. Place multiple messages into a PO file with same `msgid` but different `msgctxt` (in arbitrary order, optionally including one without a `msgctxt` as well):
   ```po
   msgctxt "context1"
   msgid "foo"
   msgstr "foo-1"

   msgid "foo"
   msgstr "foo-0"

   msgctxt "context2"
   msgid "foo"
   msgstr "foo-2"
   ```
2. Run `pybabel update` with appropriate params; the messages should be moved to the end of the file and marked obsolete:
   ```po
   #~ msgctxt "context1"
   #~ msgid "foo"
   #~ msgstr "foo-1"

   #~ msgid "foo"
   #~ msgstr "foo-0"

   #~ msgctxt "context2"
   #~ msgid "foo"
   #~ msgstr "foo-2"
   ```
3. Re-run the previous command

## Actual Results
Only the last obsolete entry with that `msgid` remains in the file (based on their order before the last update command, and regardless of whether a message without `msgctxt` was present):
```po
#~ msgctxt "context2"
#~ msgid "foo"
#~ msgstr "foo-2"
```

## Expected Results
The messages were _from different contexts_ so they should all be retained.

## Reproducibility
This issue appears to reproduce whenever the described situation occurs.

## Additional Information
Tested on Babel version 2.16.0

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/python-babel/babel/pull/1132?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 91.26%. Comparing base [(`f91754b`)](https://app.codecov.io/gh/python-babel/babel/commit/f91754b01cb9f32b83aeaa80b74ed10b5dfccb6a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) to head [(`835ba63`)](https://app.codecov.io/gh/python-babel/babel/commit/835ba638dce5b5b9bce3127a349597596582ff30?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel).

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #1132   +/-   ##
=======================================
  Coverage   91.26%   91.26%           
=======================================
  Files          27       27           
  Lines        4602     4602           
=======================================
  Hits         4200     4200           
  Misses        402      402           
```

| [Flag](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | Coverage Δ | |
|---|---|---|
| [macos-12-3.10](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.06% <100.00%> (ø)` | |
| [macos-12-3.11](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-12-3.12](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-12-3.13-dev](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-12-3.8](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-12-3.9](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-12-pypy3.10](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [ubuntu-22.04-3.10](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.09% <100.00%> (ø)` | |
| [ubuntu-22.04-3.11](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.02% <100.00%> (ø)` | |
| [ubuntu-22.04-3.12](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [ubuntu-22.04-3.13-dev](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `89.72% <100.00%> (ø)` | |
| [ubuntu-22.04-3.8](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.01% <100.00%> (ø)` | |
| [ubuntu-22.04-3.9](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.01% <100.00%> (ø)` | |
| [ubuntu-22.04-pypy3.10](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.10](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.21% <100.00%> (ø)` | |
| [windows-2022-3.11](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.12](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.13-dev](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.8](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.9](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.13% <100.00%> (ø)` | |
| [windows-2022-pypy3.10](https://app.codecov.io/gh/python-babel/babel/pull/1132/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/python-babel/babel/pull/1132?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel).


Patch:
diff --git a/babel/dates.py b/babel/dates.py
index 3373e06..2f1b116 100644
--- a/babel/dates.py
+++ b/babel/dates.py
@@ -17,6 +17,7 @@
 
 from __future__ import annotations
 
+import math
 import re
 import warnings
 from functools import lru_cache
@@ -1232,7 +1233,7 @@ def parse_date(
 
     format_str = get_date_format(format=format, locale=locale).pattern.lower()
     year_idx = format_str.index('y')
-    month_idx = format_str.index('m')
+    month_idx = format_str.find('m')
     if month_idx < 0:
         month_idx = format_str.index('l')
     day_idx = format_str.index('d')
@@ -1277,11 +1278,13 @@ def parse_time(
 
     # TODO: try ISO format first?
     format_str = get_time_format(format=format, locale=locale).pattern.lower()
-    hour_idx = format_str.index('h')
+    hour_idx = format_str.find('h')
     if hour_idx < 0:
         hour_idx = format_str.index('k')
     min_idx = format_str.index('m')
-    sec_idx = format_str.index('s')
+    # format might not contain seconds
+    if (sec_idx := format_str.find('s')) < 0:
+        sec_idx = math.inf
 
     indexes = sorted([(hour_idx, 'H'), (min_idx, 'M'), (sec_idx, 'S')])
     indexes = {item[1]: idx for idx, item in enumerate(indexes)}
@@ -1890,6 +1893,14 @@ def match_skeleton(skeleton: str, options: Iterable[str], allow_different_fields
 
     if 'z' in skeleton and not any('z' in option for option in options):
         skeleton = skeleton.replace('z', 'v')
+    if 'k' in skeleton and not any('k' in option for option in options):
+        skeleton = skeleton.replace('k', 'H')
+    if 'K' in skeleton and not any('K' in option for option in options):
+        skeleton = skeleton.replace('K', 'h')
+    if 'a' in skeleton and not any('a' in option for option in options):
+        skeleton = skeleton.replace('a', '')
+    if 'b' in skeleton and not any('b' in option for option in options):
+        skeleton = skeleton.replace('b', '')
 
     get_input_field_width = dict(t[1] for t in tokenize_pattern(skeleton) if t[0] == "field").get
     best_skeleton = None
diff --git a/babel/messages/extract.py b/babel/messages/extract.py
index 8d4bbea..1b2a37f 100644
--- a/babel/messages/extract.py
+++ b/babel/messages/extract.py
@@ -33,7 +33,7 @@ from collections.abc import (
 from functools import lru_cache
 from os.path import relpath
 from textwrap import dedent
-from tokenize import COMMENT, NAME, OP, STRING, generate_tokens
+from tokenize import COMMENT, NAME, NL, OP, STRING, generate_tokens
 from typing import TYPE_CHECKING, Any
 
 from babel.messages._compat import find_entrypoints
@@ -530,7 +530,6 @@ def extract_python(
                 in_def = False
                 continue
             if funcname:
-                message_lineno = lineno
                 call_stack += 1
         elif in_def and tok == OP and value == ':':
             # End of a class definition without parens
@@ -580,11 +579,15 @@ def extract_python(
             elif tok == STRING:
                 val = _parse_python_string(value, encoding, future_flags)
                 if val is not None:
+                    if not message_lineno:
+                        message_lineno = lineno
                     buf.append(val)
 
             # Python 3.12+, see https://peps.python.org/pep-0701/#new-tokens
             elif tok == FSTRING_START:
                 current_fstring_start = value
+                if not message_lineno:
+                    message_lineno = lineno
             elif tok == FSTRING_MIDDLE:
                 if current_fstring_start is not None:
                     current_fstring_start += value
@@ -608,6 +611,9 @@ def extract_python(
                     # for the comment to still be a valid one
                     old_lineno, old_comment = translator_comments.pop()
                     translator_comments.append((old_lineno + 1, old_comment))
+
+            elif tok != NL and not message_lineno:
+                message_lineno = lineno
         elif call_stack > 0 and tok == OP and value == ')':
             call_stack -= 1
         elif funcname and call_stack == -1:
diff --git a/babel/messages/pofile.py b/babel/messages/pofile.py
index 5cd65d8..ec2d57f 100644
--- a/babel/messages/pofile.py
+++ b/babel/messages/pofile.py
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING
 
 from babel.core import Locale
 from babel.messages.catalog import Catalog, Message
-from babel.util import _cmp, wraptext
+from babel.util import TextWrapper, _cmp
 
 if TYPE_CHECKING:
     from typing import IO, AnyStr
@@ -239,7 +239,7 @@ class PoFileParser:
                           context=msgctxt)
         if self.obsolete:
             if not self.ignore_obsolete:
-                self.catalog.obsolete[msgid] = message
+                self.catalog.obsolete[self.catalog._key_for(msgid, msgctxt)] = message
         else:
             self.catalog[msgid] = message
         self.counter += 1
@@ -247,6 +247,9 @@ class PoFileParser:
 
     def _finish_current_message(self) -> None:
         if self.messages:
+            if not self.translations:
+                self._invalid_pofile("", self.offset, f"missing msgstr for msgid '{self.messages[0].denormalize()}'")
+                self.translations.append([0, _NormalizedString("")])
             self._add_message()
 
     def _process_message_line(self, lineno, line, obsolete=False) -> None:
@@ -634,8 +637,11 @@ def generate_po(
     # provide the same behaviour
     comment_width = width if width and width > 0 else 76
 
+    comment_wrapper = TextWrapper(width=comment_width)
+    header_wrapper = TextWrapper(width=width, subsequent_indent="# ")
+
     def _format_comment(comment, prefix=''):
-        for line in wraptext(comment, comment_width):
+        for line in comment_wrapper.wrap(comment):
             yield f"#{prefix} {line.strip()}\n"
 
     def _format_message(message, prefix=''):
@@ -665,8 +671,7 @@ def generate_po(
             if width and width > 0:
                 lines = []
                 for line in comment_header.splitlines():
-                    lines += wraptext(line, width=width,
-                                      subsequent_indent='# ')
+                    lines += header_wrapper.wrap(line)
                 comment_header = '\n'.join(lines)
             yield f"{comment_header}\n"
 
diff --git a/babel/util.py b/babel/util.py
index 605695b..db82d73 100644
--- a/babel/util.py
+++ b/babel/util.py
@@ -15,6 +15,7 @@ import datetime
 import os
 import re
 import textwrap
+import warnings
 from collections.abc import Generator, Iterable
 from typing import IO, Any, TypeVar
 
@@ -217,6 +218,12 @@ def wraptext(text: str, width: int = 70, initial_indent: str = '', subsequent_in
     :param subsequent_indent: string that will be prepended to all lines save
                               the first of wrapped output
     """
+    warnings.warn(
+        "`babel.util.wraptext` is deprecated and will be removed in a future version of Babel. "
+        "If you need this functionality, use the `babel.util.TextWrapper` class directly.",
+        DeprecationWarning,
+        stacklevel=2,
+    )
     wrapper = TextWrapper(width=width, initial_indent=initial_indent,
                           subsequent_indent=subsequent_indent,
                           break_long_words=False)


Test patch:
diff --git a/tests/messages/test_extract.py b/tests/messages/test_extract.py
index 7d3a05a..bcc6aa4 100644
--- a/tests/messages/test_extract.py
+++ b/tests/messages/test_extract.py
@@ -34,6 +34,11 @@ msg7 = _(hello.there)
 msg8 = gettext('Rabbit')
 msg9 = dgettext('wiki', model.addPage())
 msg10 = dngettext(getDomain(), 'Page', 'Pages', 3)
+msg11 = ngettext(
+    "bunny",
+    "bunnies",
+    len(bunnies)
+)
 """)
         messages = list(extract.extract_python(buf,
                                                extract.DEFAULT_KEYWORDS.keys(),
@@ -49,6 +54,7 @@ msg10 = dngettext(getDomain(), 'Page', 'Pages', 3)
             (8, 'gettext', 'Rabbit', []),
             (9, 'dgettext', ('wiki', None), []),
             (10, 'dngettext', (None, 'Page', 'Pages', None), []),
+            (12, 'ngettext', ('bunny', 'bunnies', None), []),
         ]
 
     def test_extract_default_encoding_ascii(self):
@@ -97,10 +103,10 @@ add_notice(req, ngettext("Bar deleted.",
         messages = list(extract.extract_python(buf, ('ngettext', '_'), ['NOTE:'],
 
                                                {'strip_comment_tags': False}))
-        assert messages[0] == (3, 'ngettext', ('Catalog deleted.', 'Catalogs deleted.', None), ['NOTE: This Comment SHOULD Be Extracted'])
+        assert messages[0] == (2, 'ngettext', ('Catalog deleted.', 'Catalogs deleted.', None), ['NOTE: This Comment SHOULD Be Extracted'])
         assert messages[1] == (6, '_', 'Locale deleted.', ['NOTE: This Comment SHOULD Be Extracted'])
         assert messages[2] == (10, 'ngettext', ('Foo deleted.', 'Foos deleted.', None), ['NOTE: This Comment SHOULD Be Extracted'])
-        assert messages[3] == (15, 'ngettext', ('Bar deleted.', 'Bars deleted.', None), ['NOTE: This Comment SHOULD Be Extracted', 'NOTE: And This One Too'])
+        assert messages[3] == (14, 'ngettext', ('Bar deleted.', 'Bars deleted.', None), ['NOTE: This Comment SHOULD Be Extracted', 'NOTE: And This One Too'])
 
     def test_declarations(self):
         buf = BytesIO(b"""\
diff --git a/tests/messages/test_pofile.py b/tests/messages/test_pofile.py
index c0ded12..f4360ec 100644
--- a/tests/messages/test_pofile.py
+++ b/tests/messages/test_pofile.py
@@ -299,10 +299,62 @@ msgstr "Bahr"
         catalog = pofile.read_po(buf)
         assert len(catalog) == 2
         assert len(catalog.obsolete) == 1
-        message = catalog.obsolete["foo"]
+        message = catalog.obsolete[("foo", "other")]
         assert message.context == 'other'
         assert message.string == 'Voh'
 
+    def test_obsolete_messages_with_context(self):
+        buf = StringIO('''
+# This is an obsolete message
+#~ msgctxt "apple"
+#~ msgid "foo"
+#~ msgstr "Foo"
+
+# This is an obsolete message with the same id but different context
+#~ msgctxt "orange"
+#~ msgid "foo"
+#~ msgstr "Bar"
+''')
+        catalog = pofile.read_po(buf)
+        assert len(catalog) == 0
+        assert len(catalog.obsolete) == 2
+        assert 'foo' not in catalog.obsolete
+
+        apple_msg = catalog.obsolete[('foo', 'apple')]
+        assert apple_msg.id == 'foo'
+        assert apple_msg.string == 'Foo'
+        assert apple_msg.user_comments == ['This is an obsolete message']
+
+        orange_msg = catalog.obsolete[('foo', 'orange')]
+        assert orange_msg.id == 'foo'
+        assert orange_msg.string == 'Bar'
+        assert orange_msg.user_comments == ['This is an obsolete message with the same id but different context']
+
+    def test_obsolete_messages_roundtrip(self):
+        buf = StringIO('''\
+# This message is not obsolete
+#: main.py:1
+msgid "bar"
+msgstr "Bahr"
+
+# This is an obsolete message
+#~ msgid "foo"
+#~ msgstr "Voh"
+
+# This is an obsolete message
+#~ msgctxt "apple"
+#~ msgid "foo"
+#~ msgstr "Foo"
+
+# This is an obsolete message with the same id but different context
+#~ msgctxt "orange"
+#~ msgid "foo"
+#~ msgstr "Bar"
+
+''')
+        generated_po_file = ''.join(pofile.generate_po(pofile.read_po(buf), omit_header=True))
+        assert buf.getvalue() == generated_po_file
+
     def test_multiline_context(self):
         buf = StringIO('''
 msgctxt "a really long "
@@ -394,7 +446,7 @@ msgstr[1] "Vohs [text]"''')
         assert len(catalog) == 0
         assert len(catalog.obsolete) == 1
         assert catalog.num_plurals == 2
-        message = catalog.obsolete[('foo', 'foos')]
+        message = catalog.obsolete['foo']
         assert len(message.string) == 2
         assert message.string[0] == 'Voh [text]'
         assert message.string[1] == 'Vohs [text]'
@@ -1014,3 +1066,20 @@ msgstr ""
 "Language: \n"
 ''')
     assert pofile.read_po(buf).locale is None
+
+
+@pytest.mark.parametrize("case", ['msgid "foo"', 'msgid "foo"\nmsgid_plural "foos"'])
+@pytest.mark.parametrize("abort_invalid", [False, True])
+def test_issue_1134(case: str, abort_invalid: bool):
+    buf = StringIO(case)
+
+    if abort_invalid:
+        # Catalog not created, aborted with PoFileError
+        with pytest.raises(pofile.PoFileError) as excinfo:
+            pofile.read_po(buf, abort_invalid=True)
+        assert str(excinfo.value) == "missing msgstr for msgid 'foo' on 0"
+    else:
+        # Catalog is created with warning, no abort
+        output = pofile.read_po(buf)
+        assert len(output) == 1
+        assert output["foo"].string in ((''), ('', ''))
diff --git a/tests/test_dates.py b/tests/test_dates.py
index 31ac782..fef68ae 100644
--- a/tests/test_dates.py
+++ b/tests/test_dates.py
@@ -17,7 +17,7 @@ import freezegun
 import pytest
 
 from babel import Locale, dates
-from babel.dates import NO_INHERITANCE_MARKER, UTC, _localize
+from babel.dates import NO_INHERITANCE_MARKER, UTC, _localize, parse_pattern
 from babel.util import FixedOffsetTimezone
 
 
@@ -619,6 +619,19 @@ def test_format_skeleton(timezone_getter):
     assert (dates.format_skeleton('EHm', dt, tzinfo=timezone_getter('Asia/Bangkok'), locale='th') == 'อา. 22:30 น.')
 
 
+@pytest.mark.parametrize(('skeleton', 'expected'), [
+    ('Hmz', 'Hmv'),
+    ('kmv', 'Hmv'),
+    ('Kmv', 'hmv'),
+    ('Hma', 'Hm'),
+    ('Hmb', 'Hm'),
+    ('zkKab', 'vHh'),
+])
+def test_match_skeleton_alternate_characters(skeleton, expected):
+    # https://github.com/unicode-org/icu/blob/5e22f0076ec9b55056cd8a84e9ef370632f44174/icu4j/main/core/src/main/java/com/ibm/icu/text/DateIntervalInfo.java#L1090-L1102
+    assert dates.match_skeleton(skeleton, (expected,)) == expected
+
+
 def test_format_timedelta():
     assert (dates.format_timedelta(timedelta(weeks=12), locale='en_US')
             == '3 months')
@@ -666,6 +679,32 @@ def test_parse_time(input, expected):
     assert dates.parse_time(input, locale='en_US') == expected
 
 
+def test_parse_time_no_seconds_in_format():
+    # parse time using a time format which does not include seconds
+    locale = 'cs_CZ'
+    fmt = 'short'
+    assert dates.get_time_format(format=fmt, locale=locale).pattern == 'H:mm'
+    assert dates.parse_time('9:30', locale=locale, format=fmt) == time(9, 30)
+
+
+def test_parse_time_alternate_characters(monkeypatch):
+    # 'K' can be used as an alternative to 'H'
+    def get_time_format(*args, **kwargs):
+        return parse_pattern('KK:mm:ss')
+    monkeypatch.setattr(dates, 'get_time_format', get_time_format)
+
+    assert dates.parse_time('9:30') == time(9, 30)
+
+
+def test_parse_date_alternate_characters(monkeypatch):
+    # 'l' can be used as an alternative to 'm'
+    def get_date_format(*args, **kwargs):
+        return parse_pattern('yyyy-ll-dd')
+    monkeypatch.setattr(dates, 'get_time_format', get_date_format)
+
+    assert dates.parse_date('2024-10-20') == date(2024, 10, 20)
+
+
 @pytest.mark.parametrize('case', ['', 'a', 'aaa'])
 @pytest.mark.parametrize('func', [dates.parse_date, dates.parse_time])
 def test_parse_errors(case, func):
diff --git a/tests/test_numbers.py b/tests/test_numbers.py
index a96bdbe..5e6ee01 100644
--- a/tests/test_numbers.py
+++ b/tests/test_numbers.py
@@ -52,7 +52,6 @@ class FormatDecimalTestCase(unittest.TestCase):
         """
         assert numbers.format_decimal(5.5, '0', locale='sv') == '6'
         assert numbers.format_decimal(6.5, '0', locale='sv') == '6'
-        assert numbers.format_decimal(6.5, '0', locale='sv') == '6'
         assert numbers.format_decimal(1.2325, locale='sv') == '1,232'
         assert numbers.format_decimal(1.2335, locale='sv') == '1,234'