Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
74 changes: 70 additions & 4 deletions holidays/generate_ics.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# Website: https://github.com/vacanza/holidays
# License: MIT (see LICENSE file)

import re
import sys
from argparse import ArgumentParser, ArgumentTypeError, Namespace
from collections.abc import Callable
Expand Down Expand Up @@ -61,7 +62,17 @@
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,26 @@
"Use --list-languages to see supported values"
)

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

template = self.args.output_template
if not re.fullmatch(r"(?:[^{}]+|\{\{|\}\}|\{[a-z_]+\})*", template):

Check failure on line 185 in holidays/generate_ics.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure the regex used here, which is vulnerable to exponential runtime due to backtracking, cannot lead to denial of service.

See more on https://sonarcloud.io/project/issues?id=vacanza_python-holidays&issues=AZ8ykZ2q8tc1zsIw-pkF&open=AZ8ykZ2q8tc1zsIw-pkF&pullRequest=3679
raise SystemExit("Invalid output template")

fields = re.findall(r"\{([a-z_]+)\}", template)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
if not fields:
raise SystemExit("Output template must contain at least one placeholder")

for field_name in fields:
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}"
)

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

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"

def run(self) -> None:
self.validate_code()
if self.handle_list_options():
Expand All @@ -194,9 +245,24 @@
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
109 changes: 102 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,75 @@

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):
for template in ("{", "}", "{code!r}", "{code:>10}", "{code:{bad}}", "{code:{"):
with self.subTest(template=template):
with self.argv("US", "--output-template", template):
with self.assertRaises(SystemExit) as context:

Check warning on line 521 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.assertEqual(str(context.exception), "Invalid output template")

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