Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
```
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
59 changes: 55 additions & 4 deletions holidays/generate_ics.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,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 @@ -184,6 +194,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"
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 +224,30 @@ 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:
template = self.args.output_template or self.get_default_output_template()
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"),
}
try:
output_path = template.format(**values)
except KeyError as e:
supported = ", ".join(f"{{{k}}}" for k in values)
raise SystemExit(
f"Unknown placeholder '{{{e.args[0]}}}' in output template. "
f"Supported placeholders: {supported}"
)
Comment thread
KJhellico marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

try:
holiday_obj = self.entity_loader(
Expand Down
87 changes: 84 additions & 3 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,12 +456,55 @@

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: "
"{code}, {subdiv}, {language}, {categories}, "
"{start_year}, {end_year}, {today}",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def test_generate_calendar_error(self):
with patch(
Expand Down
Loading