Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
87 changes: 83 additions & 4 deletions holidays/generate_ics.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,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 +176,40 @@
"Use --list-languages to see supported values"
)

def validate_output_template(self, placeholders: set[str]) -> None:

Check failure on line 179 in holidays/generate_ics.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=vacanza_python-holidays&issues=AZ8y9NAelWsfFY_QXd8H&open=AZ8y9NAelWsfFY_QXd8H&pullRequest=3679
template = self.args.output_template
if not template:
return None

fields = []
i = 0
n = len(template)
while i < n:
if i + 1 < n and (template[i : i + 2] == "{{" or template[i : i + 2] == "}}"):
i += 2
elif template[i] == "{":
end = template.find("}", i + 1)
if end == -1:
raise SystemExit("Invalid output template")

fields.append(template[i + 1 : end])
i = end + 1
elif template[i] == "}":
raise SystemExit("Invalid output template")
else:
i += 1

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

for field_name in fields:
Comment thread
arkid15r marked this conversation as resolved.
Outdated
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 +228,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"
Comment thread
arkid15r marked this conversation as resolved.
Outdated

def run(self) -> None:
self.validate_code()
if self.handle_list_options():
Expand All @@ -194,9 +258,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
111 changes: 104 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,77 @@

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

Check warning on line 500 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),
f"Unknown placeholder '{template}' 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 512 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"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def test_output_template_invalid(self):
for template in ("{", "}", "{code:{bad}}", "{code:{", "code}"):
with self.subTest(template=template):
with self.argv("US", "--output-template", template):
with self.assertRaises(SystemExit) as context:

Check warning on line 523 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