diff --git a/docs/examples.md b/docs/examples.md index 5a40e60ac7..027e425ceb 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -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: @@ -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: @@ -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" ``` The tool can also display the supported subdivisions, categories, and languages for a selected diff --git a/holidays/generate_ics.py b/holidays/generate_ics.py index 04201269d8..1aac33ec15 100644 --- a/holidays/generate_ics.py +++ b/holidays/generate_ics.py @@ -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( @@ -166,6 +176,40 @@ def validate_language(self) -> None: "Use --list-languages to see supported values" ) + def validate_output_template(self, placeholders: set[str]) -> None: + 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: + 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}:") @@ -184,6 +228,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" + def run(self) -> None: self.validate_code() if self.handle_list_options(): @@ -194,9 +258,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( diff --git a/tests/test_generate_ics.py b/tests/test_generate_ics.py index 9a60227dfe..9ae30dec96 100644 --- a/tests/test_generate_ics.py +++ b/tests/test_generate_ics.py @@ -403,6 +403,44 @@ def test_filename_default(self): 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"): @@ -418,18 +456,77 @@ def test_filename_year_offset(self): 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: + 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: + 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:{bad}}", "{code:{", "code}"): + with self.subTest(template=template): + with self.argv("US", "--output-template", template): + with self.assertRaises(SystemExit) as context: + 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()