https://github.com/python/cpython/commit/2ba0a81c9117ff5e4aa405b568f6dad62d409ab0
commit: 2ba0a81c9117ff5e4aa405b568f6dad62d409ab0
branch: main
author: Hugo van Kemenade <[email protected]>
committer: hugovk <[email protected]>
date: 2026-05-04T15:14:57+03:00
summary:

gh-148352: Add more colour to `calendar` CLI output (#148354)

Co-authored-by: Rihaan Meher <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-04-10-20-23-22.gh-issue-148352.lrec3W.rst
M Doc/library/calendar.rst
M Doc/whatsnew/3.15.rst
M Lib/_colorize.py
M Lib/calendar.py
M Lib/test/test_calendar.py

diff --git a/Doc/library/calendar.rst b/Doc/library/calendar.rst
index 1c8f25e96dcd6c..3eb3f741eee45a 100644
--- a/Doc/library/calendar.rst
+++ b/Doc/library/calendar.rst
@@ -756,6 +756,11 @@ The following options are accepted:
    By default, today's date is highlighted in color and can be
    :ref:`controlled using environment variables <using-on-controlling-color>`.
 
+.. versionchanged:: next
+   By default, the month is now also highlighted in color, and
+   the days of the week are also in color. This behavior can be
+   :ref:`controlled using environment variables <using-on-controlling-color>`.
+
 *HTML-mode options:*
 
 .. option:: --css CSS, -c CSS
diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst
index 02a3a11e0564d9..2a493010ece0fd 100644
--- a/Doc/whatsnew/3.15.rst
+++ b/Doc/whatsnew/3.15.rst
@@ -832,14 +832,19 @@ binascii
 calendar
 --------
 
-* Calendar pages generated by the :class:`calendar.HTMLCalendar` class now 
support
-  dark mode and have been migrated to the HTML5 standard for improved 
accessibility.
-  (Contributed by Jiahao Li and Hugo van Kemenade in :gh:`137634`.)
+* :mod:`calendar`'s :ref:`command-line <calendar-cli>` text output has more
+  color. This can be controlled with :ref:`environment variables
+  <using-on-controlling-color>`.
+  (Contributed by Hugo van Kemenade in :gh:`148352`.)
 
 * The :mod:`calendar`'s :ref:`command-line <calendar-cli>` HTML output now
   accepts the year-month option: ``python -m calendar -t html 2009 06``.
   (Contributed by Pål Grønås Drange in :gh:`140212`.)
 
+* Calendar pages generated by the :class:`calendar.HTMLCalendar` class now 
support
+  dark mode and have been migrated to the HTML5 standard for improved 
accessibility.
+  (Contributed by Jiahao Li and Hugo van Kemenade in :gh:`137634`.)
+
 
 collections
 -----------
diff --git a/Lib/_colorize.py b/Lib/_colorize.py
index 7c500e557f0180..5e0c0124e597b8 100644
--- a/Lib/_colorize.py
+++ b/Lib/_colorize.py
@@ -200,6 +200,14 @@ class Ast(ThemeSection):
     reset: str = ANSIColors.RESET
 
 
+@dataclass(frozen=True, kw_only=True)
+class Calendar(ThemeSection):
+    header: str = ANSIColors.BOLD
+    highlight: str = ANSIColors.BLACK + ANSIColors.BACKGROUND_YELLOW
+    weekday: str = ANSIColors.CYAN
+    reset: str = ANSIColors.RESET
+
+
 @dataclass(frozen=True, kw_only=True)
 class Difflib(ThemeSection):
     """A 'git diff'-like theme for `difflib.unified_diff`."""
@@ -459,6 +467,7 @@ class Theme:
     """
     argparse: Argparse = field(default_factory=Argparse)
     ast: Ast = field(default_factory=Ast)
+    calendar: Calendar = field(default_factory=Calendar)
     difflib: Difflib = field(default_factory=Difflib)
     fancycompleter: FancyCompleter = field(default_factory=FancyCompleter)
     http_server: HttpServer = field(default_factory=HttpServer)
@@ -476,6 +485,7 @@ def copy_with(
         *,
         argparse: Argparse | None = None,
         ast: Ast | None = None,
+        calendar: Calendar | None = None,
         difflib: Difflib | None = None,
         fancycompleter: FancyCompleter | None = None,
         http_server: HttpServer | None = None,
@@ -496,6 +506,7 @@ def copy_with(
         return type(self)(
             argparse=argparse or self.argparse,
             ast=ast or self.ast,
+            calendar=calendar or self.calendar,
             difflib=difflib or self.difflib,
             fancycompleter=fancycompleter or self.fancycompleter,
             http_server=http_server or self.http_server,
@@ -520,6 +531,7 @@ def no_colors(cls) -> Self:
         return cls(
             argparse=Argparse.no_colors(),
             ast=Ast.no_colors(),
+            calendar=Calendar.no_colors(),
             difflib=Difflib.no_colors(),
             fancycompleter=FancyCompleter.no_colors(),
             http_server=HttpServer.no_colors(),
diff --git a/Lib/calendar.py b/Lib/calendar.py
index d80c3fd9524776..fa9775ab040b14 100644
--- a/Lib/calendar.py
+++ b/Lib/calendar.py
@@ -686,28 +686,61 @@ def __init__(self, highlight_day=None, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.highlight_day = highlight_day
 
-    def formatweek(self, theweek, width, *, highlight_day=None):
+    def _get_theme(self):
+        from _colorize import get_theme
+
+        return get_theme(tty_file=sys.stdout)
+
+    def formatday(self, day, weekday, width, *, highlight_day=None):
         """
-        Returns a single week in a string (no newline).
+        Returns a formatted day.
         """
-        if highlight_day:
-            from _colorize import get_colors
-
-            ansi = get_colors()
-            highlight = f"{ansi.BLACK}{ansi.BACKGROUND_YELLOW}"
-            reset = ansi.RESET
+        if day == 0:
+            s = ''
         else:
-            highlight = reset = ""
+            s = f'{day:2}'
+        s = s.center(width)
+        if day == highlight_day:
+            theme = self._get_theme().calendar
+            s = f"{theme.highlight}{s}{theme.reset}"
+        return s
 
+    def formatweek(self, theweek, width, *, highlight_day=None):
+        """
+        Returns a single week in a string (no newline).
+        """
         return ' '.join(
-            (
-                f"{highlight}{self.formatday(d, wd, width)}{reset}"
-                if d == highlight_day
-                else self.formatday(d, wd, width)
-            )
+            self.formatday(d, wd, width, highlight_day=highlight_day)
             for (d, wd) in theweek
         )
 
+    def formatweekheader(self, width):
+        """
+        Return a header for a week.
+        """
+        header = super().formatweekheader(width)
+        theme = self._get_theme().calendar
+        return f"{theme.weekday}{header}{theme.reset}"
+
+    def formatmonthname(self, theyear, themonth, width, withyear=True):
+        """
+        Return a formatted month name.
+        """
+        name = super().formatmonthname(theyear, themonth, width, withyear)
+        theme = self._get_theme().calendar
+        if (
+            self.highlight_day
+            and self.highlight_day.year == theyear
+            and self.highlight_day.month == themonth
+        ):
+            color = theme.highlight
+            name_only = name.strip()
+            colored_name = f"{color}{name_only}{theme.reset}"
+            return name.replace(name_only, colored_name, 1)
+        else:
+            color = theme.header
+        return f"{color}{name}{theme.reset}"
+
     def formatmonth(self, theyear, themonth, w=0, l=0):
         """
         Return a month's calendar string (multi-line).
@@ -742,7 +775,9 @@ def formatyear(self, theyear, w=2, l=1, c=6, m=3):
         colwidth = (w + 1) * 7 - 1
         v = []
         a = v.append
-        a(repr(theyear).center(colwidth*m+c*(m-1)).rstrip())
+        theme = self._get_theme().calendar
+        year = repr(theyear).center(colwidth*m+c*(m-1)).rstrip()
+        a(f"{theme.header}{year}{theme.reset}")
         a('\n'*l)
         header = self.formatweekheader(w)
         for (i, row) in enumerate(self.yeardays2calendar(theyear, m)):
@@ -843,28 +878,30 @@ def timegm(tuple):
 
 def main(args=None):
     import argparse
-    parser = argparse.ArgumentParser(color=True)
+    parser = argparse.ArgumentParser(
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+    )
     textgroup = parser.add_argument_group('text only arguments')
     htmlgroup = parser.add_argument_group('html only arguments')
     textgroup.add_argument(
         "-w", "--width",
         type=int, default=2,
-        help="width of date column (default 2)"
+        help="width of date column"
     )
     textgroup.add_argument(
         "-l", "--lines",
         type=int, default=1,
-        help="number of lines for each week (default 1)"
+        help="number of lines for each week"
     )
     textgroup.add_argument(
         "-s", "--spacing",
         type=int, default=6,
-        help="spacing between months (default 6)"
+        help="spacing between months"
     )
     textgroup.add_argument(
         "-m", "--months",
         type=int, default=3,
-        help="months per row (default 3)"
+        help="months per row"
     )
     htmlgroup.add_argument(
         "-c", "--css",
@@ -879,7 +916,7 @@ def main(args=None):
     parser.add_argument(
         "-e", "--encoding",
         default=None,
-        help="encoding to use for output (default utf-8)"
+        help="encoding to use for output"
     )
     parser.add_argument(
         "-t", "--type",
@@ -890,7 +927,7 @@ def main(args=None):
     parser.add_argument(
         "-f", "--first-weekday",
         type=int, default=0,
-        help="weekday (0 is Monday, 6 is Sunday) to start each week (default 
0)"
+        help="weekday (0 is Monday, 6 is Sunday) to start each week"
     )
     parser.add_argument(
         "year",
diff --git a/Lib/test/test_calendar.py b/Lib/test/test_calendar.py
index 79f0ebb78ffe9c..6ed27b4095bc1d 100644
--- a/Lib/test/test_calendar.py
+++ b/Lib/test/test_calendar.py
@@ -1068,6 +1068,7 @@ def test_several_leapyears_in_range(self):
 def conv(s):
     return s.replace('\n', os.linesep).encode()
 
[email protected]_not_colorized_test_class
 class CommandLineTestCase(unittest.TestCase):
     def setUp(self):
         self.runners = [self.run_cli_ok, self.run_cmd_ok]
@@ -1121,7 +1122,6 @@ def assertFailure(self, *args):
         self.assertCLIFails(*args)
         self.assertCmdFails(*args)
 
-    @support.force_not_colorized
     def test_help(self):
         stdout = self.run_cmd_ok('-h')
         self.assertIn(b'usage:', stdout)
@@ -1256,6 +1256,15 @@ def test_html_output_year_css(self):
             self.assertIn(b'<link rel="stylesheet" href="custom.css">', output)
 
 
[email protected]_colorized_test_class
+class ColorTestCase(unittest.TestCase):
+    def test_formatmonth_color(self):
+        today = datetime.date(2026, 5, 4)
+        cal = calendar._CLIDemoCalendar(highlight_day=today)
+        output = cal.formatmonth(2026, 5)
+        self.assertIn("\x1b[30m\x1b[43mMay 2026\x1b[0m\n\x1b[36m", output)
+
+
 class MiscTestCase(unittest.TestCase):
     def test__all__(self):
         not_exported = {
diff --git 
a/Misc/NEWS.d/next/Library/2026-04-10-20-23-22.gh-issue-148352.lrec3W.rst 
b/Misc/NEWS.d/next/Library/2026-04-10-20-23-22.gh-issue-148352.lrec3W.rst
new file mode 100644
index 00000000000000..508b36791ea0e2
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-04-10-20-23-22.gh-issue-148352.lrec3W.rst
@@ -0,0 +1 @@
+Add more color to :mod:`calendar`'s CLI output. Patch by Hugo van Kemenade.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to