Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,7 @@ the [icalendar](https://github.com/collective/icalendar) package.
For user convenience, the library includes `holidays-ics` tool for generating iCalendar (.ics)
files from holiday calendars provided by the library. It supports country, subdivision,
and financial market calendars, holiday category filtering, localized holiday names,
year selection, and custom output file names.
year selection, custom output paths, and output filename templates.

The tool can be run either as an installed command:

Expand All @@ -625,10 +625,29 @@ a Python installation or any manual setup of `holidays` package.
For installation instructions, see `uv` [documentation](https://docs.astral.sh/uv/getting-started/installation/).

By default, the generated calendar contains holidays for the current year and is written
to a file whose name is derived from the selected calendar and year range.
to a file whose name is derived from the selected options and year range.
The `--years` option accepts a single year (2025), a year range (2025-2027), or a
relative range (+10), which selects the current year through the current year plus 10.

### Output filename templates

Instead of specifying an explicit output path with `--output`, you can use
`--output-template` to generate output filenames dynamically.

Supported placeholders:

| Placeholder | Description |
|----------------|------------------------------------------------------------------|
| `{code}` | Country or financial market code |
| `{subdiv}` | Subdivision code, or `ALL` if not specified |
| `{language}` | Requested language, or `DEFAULT` if not specified |
| `{categories}` | Holiday categories joined with `_`, or `PUBLIC` if not specified |
| `{start_year}` | First year |
| `{end_year}` | Last year |
| `{today}` | Current date in `YYYYMMDD` format |

To include literal `{` or `}` characters, write them as `{{` and `}}`.

### Examples

Calendar for the current year:
Expand Down Expand Up @@ -694,13 +713,13 @@ holidays-ics XNYS
Spanning the next 10 years, unofficial holidays, saved to a custom file:

```shell
holidays-ics US --years +10 --categories unofficial --output US_YYYY_YYYY_unofficial.ics
holidays-ics US --years +10 --categories unofficial --output-template "HOLIDAYS_{code}_{start_year}_{end_year}_{categories}.ics"
```

Calendar for Switzerland, specific to the Canton of Zurich, localized in German, and saved to a custom file.
Calendar for Switzerland, specific to the Canton of Zurich, localized in German, and saved to a custom file:

```shell
holidays-ics CH --subdiv ZH --language de --output CH_ZH_de.ics
holidays-ics CH --subdiv ZH --language de --output-template "{code}_{subdiv}_{language}_{today}.ics"
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

The tool can also display the supported subdivisions, categories, and languages for a selected
Expand Down
76 changes: 72 additions & 4 deletions holidays/generate_ics.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from argparse import ArgumentParser, ArgumentTypeError, Namespace
from collections.abc import Callable
from datetime import datetime, timezone
from string import Formatter

import holidays
from holidays.holiday_base import HolidayBase
Expand Down Expand Up @@ -61,7 +62,17 @@ def __init__(self):
parser.add_argument(
"-l", "--language", help="Language code for holiday names (e.g., en_US, es)"
)
parser.add_argument("-o", "--output", help="Output file path (e.g., holidays.ics)")

output_group = parser.add_mutually_exclusive_group()
output_group.add_argument("-o", "--output", help="Output file path")
output_group.add_argument(
"--output-template",
help=(
"Output filename template. Available placeholders: {code}, {subdiv}, "
"{language}, {categories}, {start_year}, {end_year}, {today}. "
"Use '{{' and '}}' for literal '{' and '}'"
),
)

list_group = parser.add_mutually_exclusive_group()
list_group.add_argument(
Expand Down Expand Up @@ -166,6 +177,28 @@ def validate_language(self) -> None:
"Use --list-languages to see supported values"
)

def validate_output_template(self, placeholders: set[str]) -> None:
if not self.args.output_template:
return None

has_placeholder = False
try:
for _, field_name, _, _ in Formatter().parse(self.args.output_template):
Comment thread
KJhellico marked this conversation as resolved.
Outdated
if field_name is None:
continue
has_placeholder = True
if field_name not in placeholders:
supported = ", ".join(f"{{{p}}}" for p in sorted(placeholders))
raise SystemExit(
f"Unknown placeholder '{{{field_name}}}' in output template. "
f"Supported placeholders: {supported}"
)
except ValueError as e:
raise SystemExit(f"Invalid output template: {e}")

if not has_placeholder:
raise SystemExit("Output template must contain at least one placeholder")

def handle_list_options(self) -> bool:
if self.args.list_subdivisions:
print(f"Supported subdivisions for {self.args.code}:")
Expand All @@ -184,6 +217,26 @@ def handle_list_options(self) -> bool:

return False

def get_default_output_template(self) -> str:
start_year, end_year = self.args.years
parts = ["{code}"]

if self.args.subdiv:
parts.append("{subdiv}")

if self.args.language:
parts.append("{language}")

if self.args.categories:
parts.append("{categories}")

parts.append("{start_year}")

if start_year != end_year:
parts.append("{end_year}")

return "_".join(parts) + ".ics"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not good, str + in Python is expensive.


def run(self) -> None:
self.validate_code()
if self.handle_list_options():
Expand All @@ -194,9 +247,24 @@ def run(self) -> None:
self.validate_categories()

start_year, end_year = self.args.years
years_part = f"{start_year}_{end_year}" if start_year != end_year else f"{start_year}"
subdiv_part = f"_{self.args.subdiv.upper().replace(' ', '_')}" if self.args.subdiv else ""
output_path = self.args.output or f"{self.args.code}{subdiv_part}_{years_part}.ics"

if self.args.output:
output_path = self.args.output
else:
values = {
"code": self.args.code,
"subdiv": (self.args.subdiv or "ALL").upper().replace(" ", "_"),
"language": self.args.language.upper() if self.args.language else "DEFAULT",
"categories": (
"_".join(self.args.categories).upper() if self.args.categories else "PUBLIC"
),
"start_year": start_year,
"end_year": end_year,
"today": datetime.now(timezone.utc).strftime("%Y%m%d"),
}
self.validate_output_template(set(values))
template = self.args.output_template or self.get_default_output_template()
output_path = template.format(**values)

try:
holiday_obj = self.entity_loader(
Expand Down
107 changes: 100 additions & 7 deletions tests/test_generate_ics.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,44 @@

self.assertTrue((temp_dir / "US_2024.ics").exists())

def test_filename_subdivision(self):
with self.temp_cwd() as temp_dir:
with self.argv("US", "--subdiv", "CA", "--years", "2025"):
IcsGenerator().run()

self.assertTrue((temp_dir / "US_CA_2025.ics").exists())

def test_filename_language(self):
with self.temp_cwd() as temp_dir:
with self.argv("AT", "--language", "uk", "--years", "2025"):
IcsGenerator().run()

self.assertTrue((temp_dir / "AT_UK_2025.ics").exists())

def test_filename_categories(self):
with self.temp_cwd() as temp_dir:
with self.argv("AT", "--categories", "bank", "--years", "2025"):
IcsGenerator().run()

self.assertTrue((temp_dir / "AT_BANK_2025.ics").exists())

def test_filename_language_categories_subdivision(self):
with self.temp_cwd() as temp_dir:
with self.argv(
"AT",
"--subdiv",
"1",
"--language",
"uk",
"--categories",
"bank,public",
"--years",
"2025",
):
IcsGenerator().run()

self.assertTrue((temp_dir / "AT_1_UK_BANK_PUBLIC_2025.ics").exists())

def test_filename_year_range(self):
with self.temp_cwd() as temp_dir:
with self.argv("US", "--years", "2024-2026"):
Expand All @@ -418,18 +456,73 @@

self.assertTrue((temp_dir / "US_2025_2031.ics").exists())

def test_filename_subdivision(self):
def test_output_template(self):
with self.temp_cwd() as temp_dir:
with self.argv("US", "--subdiv", "CA", "--years", "2025"):
with self.argv(
"US", "--years", "2025", "--output-template", "{start_year}_{code}.ics"
):
IcsGenerator().run()

self.assertTrue((temp_dir / "US_CA_2025.ics").exists())
self.assertTrue((temp_dir / "2025_US.ics").exists())

def test_output_template_default_values(self):
with self.temp_cwd() as temp_dir:
with self.argv(
"US",
"--years",
"2025",
"--output-template",
"{code}_{subdiv}_{language}_{categories}.ics",
):
IcsGenerator().run()

self.assertTrue((temp_dir / "US_ALL_DEFAULT_PUBLIC.ics").exists())

@patch("holidays.generate_ics.datetime", MockDatetime)
def test_output_template_today(self):
with self.temp_cwd() as temp_dir:
with self.argv("US", "--years", "2025", "--output-template", "{code}_{today}.ics"):
IcsGenerator().run()

self.assertTrue((temp_dir / "US_20250701.ics").exists())

def test_output_template_with_braces(self):
with self.temp_cwd() as temp_dir:
with self.argv("US", "--years", "2025", "--output-template", "{{{code}}}.ics"):
IcsGenerator().run()

self.assertTrue((temp_dir / "{US}.ics").exists())

def test_output_template_unknown_placeholder(self):
with self.argv("US", "--output-template", "{foo}.ics"):
with self.assertRaises(SystemExit) as context:

Check warning on line 498 in tests/test_generate_ics.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=vacanza_python-holidays&issues=AZ8vzq8GPgOMskWIQp0V&open=AZ8vzq8GPgOMskWIQp0V&pullRequest=3679
IcsGenerator().run()

self.assertEqual(
str(context.exception),
"Unknown placeholder '{foo}' in output template. "
"Supported placeholders: {categories}, {code}, {end_year}, {language}, "
"{start_year}, {subdiv}, {today}",
)

def test_output_template_without_placeholders(self):
with self.argv("US", "--output-template", "calendar.ics"):
with self.assertRaises(SystemExit) as context:

Check warning on line 510 in tests/test_generate_ics.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=vacanza_python-holidays&issues=AZ8yNwvGlWsfFY_QQa4Y&open=AZ8yNwvGlWsfFY_QQa4Y&pullRequest=3679
IcsGenerator().run()

self.assertEqual(
str(context.exception), "Output template must contain at least one placeholder"
)

def test_output_template_invalid(self):
with self.argv("US", "--output-template", "{"):
with self.assertRaises(SystemExit) as context:

Check warning on line 519 in tests/test_generate_ics.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=vacanza_python-holidays&issues=AZ8yNwvGlWsfFY_QQa4Z&open=AZ8yNwvGlWsfFY_QQa4Z&pullRequest=3679
IcsGenerator().run()

self.assertIn("Invalid output template:", str(context.exception))

def test_generate_calendar_error(self):
with patch(
"holidays.ical.ICalExporter.save_ics",
side_effect=ValueError("unknown error"),
):
with patch("holidays.ical.ICalExporter.save_ics", side_effect=ValueError("unknown error")):
with self.argv("US"):
with self.assertRaises(SystemExit) as context:
IcsGenerator().run()
Expand Down
Loading