diff --git a/.vscode/xcdat.code-workspace b/.vscode/xcdat.code-workspace
index 906eb68e..8eaa4e47 100644
--- a/.vscode/xcdat.code-workspace
+++ b/.vscode/xcdat.code-workspace
@@ -61,7 +61,7 @@
         "configurations": [
             {
                 "name": "Python: Current File",
-                "type": "python",
+                "type": "debugpy",
                 "request": "launch",
                 "program": "${file}",
                 "console": "integratedTerminal",
diff --git a/xcdat/temporal.py b/xcdat/temporal.py
index 834eb394..5fb0d736 100644
--- a/xcdat/temporal.py
+++ b/xcdat/temporal.py
@@ -747,16 +747,16 @@ def departures(
         # calling the group average or climatology APIs in step #3.
         ds = self._preprocess_dataset(ds)

-        # 3. Calculate the grouped average and climatology of the data variable.
+        # 3. Get the observational data variable.
         # ----------------------------------------------------------------------
-        # The climatology and grouped average APIs are called on the copied
-        # dataset to create separate instances of the `TemporalAccessor` class.
-        # This is done to avoid overriding the attributes of the current
-        # instance of `TemporalAccessor` (set in step #1 above).
+        # NOTE: The xCDAT APIs are called on copies of the original dataset to
+        # create separate instances of the `TemporalAccessor` class. This is
+        # done to avoid overriding the attributes of the current instance of
+        # `TemporalAccessor`, which is set by step #1.
+        ds_obs = ds.copy()

         # Group averaging is only required if the dataset's frequency (input)
         # differs from the `freq` arg (output).
-        ds_obs = ds.copy()
         inferred_freq = _infer_freq(ds[self.dim])
         if inferred_freq != freq:
             ds_obs = ds_obs.temporal.group_average(
@@ -767,7 +767,10 @@ def departures(
                 season_config,
             )

-        ds_climo = ds.temporal.climatology(
+        # 4. Calculate the climatology of the data variable.
+        # ----------------------------------------------------------------------
+        ds_climo = ds.copy()
+        ds_climo = ds_climo.temporal.climatology(
             data_var,
             freq,
             weighted,
@@ -776,44 +779,15 @@ def departures(
             season_config,
         )

-        # 4. Group the averaged data variable values by the time `freq`.
+        # 5. Calculate the departures for the data variable.
         # ----------------------------------------------------------------------
-        # This step allows us to perform xarray's grouped arithmetic to
-        # calculate departures.
-        dv_obs = ds_obs[data_var].copy()
-        self._labeled_time = self._label_time_coords(dv_obs[self.dim])
-        dv_obs_grouped = self._group_data(dv_obs)
-
-        # 5. Align time dimension names using the labeled time dimension name.
-        # ----------------------------------------------------------------------
-        # The climatology's time dimension is renamed to the labeled time
-        # dimension in step #4 above (e.g., "time" -> "season"). xarray requires
-        # dimension names to be aligned to perform grouped arithmetic, which we
-        # use for calculating departures in step #5. Otherwise, this error is
-        # raised: "`ValueError: incompatible dimensions for a grouped binary
-        # operation: the group variable '<FREQ ARG>' is not a dimension on the
-        # other argument`".
-        dv_climo = ds_climo[data_var]
-        dv_climo = dv_climo.rename({self.dim: self._labeled_time.name})
-
-        # 6. Calculate the departures for the data variable.
-        # ----------------------------------------------------------------------
-        # departures = observation - climatology
-        with xr.set_options(keep_attrs=True):
-            dv_departs = dv_obs_grouped - dv_climo
-            dv_departs = self._add_operation_attrs(dv_departs)
-            ds_obs[data_var] = dv_departs
-
-            # The original time dimension name is restored after grouped
-            # arithmetic, so the labeled time dimension name is no longer needed
-            # and therefore dropped.
-            ds_obs = ds_obs.drop_vars(str(self._labeled_time.name))
+        ds_departs = self._calculate_departures(ds_obs, ds_climo, data_var)

         if weighted and keep_weights:
-            self._weights = ds_climo.time_wts
-            ds_obs = self._keep_weights(ds_obs)
+            self._weights = ds_climo[f"{self.dim}_wts"]
+            ds_departs = self._keep_weights(ds_departs)

-        return ds_obs
+        return ds_departs

     def _averager(
         self,
@@ -1196,6 +1170,7 @@ def _group_average(self, ds: xr.Dataset, data_var: str) -> xr.DataArray:
         # Label the time coordinates for grouping weights and the data variable
         # values.
         self._labeled_time = self._label_time_coords(dv[self.dim])
+        dv = dv.assign_coords({self.dim: self._labeled_time})

         if self._weighted:
             time_bounds = ds.bounds.get_bounds("T", var_key=data_var)
@@ -1222,14 +1197,6 @@ def _group_average(self, ds: xr.Dataset, data_var: str) -> xr.DataArray:
         else:
             dv = self._group_data(dv).mean()

-        # After grouping and aggregating the data variable values, the
-        # original time dimension is replaced with the grouped time dimension.
-        # For example, grouping on "year_season" replaces the time dimension
-        # with "year_season". This dimension needs to be renamed back to
-        # the original time dimension name before the data variable is added
-        # back to the dataset so that the original name is preserved.
-        dv = dv.rename({self._labeled_time.name: self.dim})
-
         # After grouping and aggregating, the grouped time dimension's
         # attributes are removed. Xarray's `keep_attrs=True` option only keeps
         # attributes for data variables and not their coordinates, so the
@@ -1254,9 +1221,6 @@ def _get_weights(self, time_bounds: xr.DataArray) -> xr.DataArray:
         divided by the total sum of the time lengths in its group to get its
         corresponding weight.

-        The sum of the weights for each group is validated to ensure it equals
-        1.0.
-
         Parameters
         ----------
         time_bounds : xr.DataArray
@@ -1285,7 +1249,7 @@ def _get_weights(self, time_bounds: xr.DataArray) -> xr.DataArray:
         # or time unit (with rare exceptions see release notes). To avoid this
         # warning please use the scalar types `np.float64`, or string notation.`
         if isinstance(time_lengths.data, Array):
-            time_lengths.load()
+            time_lengths = time_lengths.astype("timedelta64[ns]")

         time_lengths = time_lengths.astype(np.float64)

@@ -1293,11 +1257,6 @@ def _get_weights(self, time_bounds: xr.DataArray) -> xr.DataArray:
         weights: xr.DataArray = grouped_time_lengths / grouped_time_lengths.sum()
         weights.name = f"{self.dim}_wts"

-        # Validate the sum of weights for each group is 1.0.
-        actual_sum = self._group_data(weights).sum().values
-        expected_sum = np.ones(len(grouped_time_lengths.groups))
-        np.testing.assert_allclose(actual_sum, expected_sum)
-
         return weights

     def _group_data(self, data_var: xr.DataArray) -> DataArrayGroupBy:
@@ -1321,8 +1280,8 @@ def _group_data(self, data_var: xr.DataArray) -> DataArrayGroupBy:
         if self._mode == "average":
             dv_gb = dv.groupby(f"{self.dim}.{self._freq}")
         else:
-            dv.coords[self._labeled_time.name] = self._labeled_time
-            dv_gb = dv.groupby(self._labeled_time.name)
+            dv = dv.assign_coords({self.dim: self._labeled_time})
+            dv_gb = dv.groupby(self.dim)

         return dv_gb

@@ -1374,9 +1333,9 @@ def _label_time_coords(self, time_coords: xr.DataArray) -> xr.DataArray:
         dt_objects = self._convert_df_to_dt(df_dt_components)

         time_grouped = xr.DataArray(
-            name="_".join(df_dt_components.columns),
+            name=self.dim,
             data=dt_objects,
-            coords={self.dim: time_coords[self.dim]},
+            coords={self.dim: dt_objects},
             dims=[self.dim],
             attrs=time_coords[self.dim].attrs,
         )
@@ -1669,6 +1628,10 @@ def _convert_df_to_dt(self, df: pd.DataFrame) -> np.ndarray:
     def _keep_weights(self, ds: xr.Dataset) -> xr.Dataset:
         """Keep the weights in the dataset.

+        The labeled time coordinates for the weights are replaced with the
+        original time coordinates and the dimension name is appended with
+        "_original".
+
         Parameters
         ----------
         ds : xr.Dataset
@@ -1679,16 +1642,13 @@ def _keep_weights(self, ds: xr.Dataset) -> xr.Dataset:
         xr.Dataset
             The dataset with the weights used for averaging.
         """
-        # Append "_original" to the name of the weights` time coordinates to
-        # avoid conflict with the grouped time coordinates in the Dataset (can
-        # have a different shape).
         if self._mode in ["group_average", "climatology"]:
-            self._weights = self._weights.rename({self.dim: f"{self.dim}_original"})
-            # Only keep the original time coordinates, not the ones labeled
-            # by group.
-            self._weights = self._weights.drop_vars(self._labeled_time.name)
+            weights = self._weights.assign_coords({self.dim: self._dataset[self.dim]})
+            weights = weights.rename({self.dim: f"{self.dim}_original"})
+        else:
+            weights = self._weights

-        ds[self._weights.name] = self._weights
+        ds[weights.name] = weights

         return ds

@@ -1733,6 +1693,59 @@ def _add_operation_attrs(self, data_var: xr.DataArray) -> xr.DataArray:

         return data_var

+    def _calculate_departures(
+        self,
+        ds_obs: xr.Dataset,
+        ds_climo: xr.Dataset,
+        data_var: str,
+    ) -> xr.Dataset:
+        """Calculate the departures for a data variable.
+
+        How this methods works:
+
+            1. Label the observational data variable's time coordinates by their
+               appropriate time group. For example, the first two time
+               coordinates 2000-01-01 and 2000-02-01 are replaced with the
+               "01-01-01" and "01-02-01" monthly groups.
+            2. Calculate departures by subtracting the climatology from the
+               labeled observational data using Xarray's grouped arithmetic with
+               automatic broadcasting (departures = obs - climo).
+            3. Restore the original time coordinates to the departures variable
+               to preserve the "year" of the time coordinates. For example,
+               the first two time coordinates 01-01-01 and 01-02-01 are reverted
+               back to 2000-01-01 and 2000-02-01.
+
+        Parameters
+        ----------
+        ds_obs : xr.Dataset
+            The observational dataset.
+        dv_climo : xr.Dataset
+            The climatology dataset.
+        data_var : str
+            The key of the data variable for calculating departures.
+
+        Returns
+        -------
+        xr.Dataset
+            The dataset containing the departures for a data variable.
+        """
+        ds_departs = ds_obs.copy()
+
+        dv_obs = ds_obs[data_var].copy()
+        self._labeled_time = self._label_time_coords(dv_obs[self.dim])
+        dv_obs_grouped = self._group_data(dv_obs)
+
+        dv_climo = ds_climo[data_var].copy()
+
+        with xr.set_options(keep_attrs=True):
+            dv_departs = dv_obs_grouped - dv_climo
+
+        dv_departs = self._add_operation_attrs(dv_departs)
+        dv_departs = dv_departs.assign_coords({self.dim: ds_obs[self.dim]})
+        ds_departs[data_var] = dv_departs
+
+        return ds_departs
+

 def _infer_freq(time_coords: xr.DataArray) -> Frequency:
     """Infers the time frequency from the coordinates.

diff --git a/tests/test_temporal.py b/tests/test_temporal.py
index 6e2d6049..e5489b1b 100644
--- a/tests/test_temporal.py
+++ b/tests/test_temporal.py
@@ -121,7 +121,7 @@ def test_averages_for_yearly_time_series(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

         # Test unweighted averages
         result = ds.temporal.average("ts", weighted=False)
@@ -139,7 +139,7 @@ def test_averages_for_yearly_time_series(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_averages_for_monthly_time_series(self):
         # Set up dataset
@@ -293,7 +293,7 @@ def test_averages_for_daily_time_series(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

         # Test unweighted averages
         result = ds.temporal.average("ts", weighted=False)
@@ -310,7 +310,7 @@ def test_averages_for_daily_time_series(self):
                 "weighted": "False",
             },
         )
-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_averages_for_hourly_time_series(self):
         ds = xr.Dataset(
@@ -378,7 +378,7 @@ def test_averages_for_hourly_time_series(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

         # Test unweighted averages
         result = ds.temporal.average("ts", weighted=False)
@@ -396,7 +396,7 @@ def test_averages_for_hourly_time_series(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

 class TestGroupAverage:
@@ -619,7 +619,7 @@ def test_weighted_seasonal_averages_with_DJF_and_drop_incomplete_seasons(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_seasonal_averages_with_DJF_without_dropping_incomplete_seasons(
         self,
@@ -670,7 +670,7 @@ def test_weighted_seasonal_averages_with_DJF_without_dropping_incomplete_seasons
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_seasonal_averages_with_JFD(self):
         ds = self.ds.copy()
@@ -729,7 +729,7 @@ def test_weighted_seasonal_averages_with_JFD(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_custom_seasonal_averages(self):
         ds = self.ds.copy()
@@ -787,7 +787,7 @@ def test_weighted_custom_seasonal_averages(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_raises_error_with_incorrect_custom_seasons_argument(self):
         # Test raises error with non-3 letter strings
@@ -873,7 +873,7 @@ def test_weighted_monthly_averages(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_monthly_averages_with_masked_data(self):
         ds = self.ds.copy()
@@ -924,7 +924,7 @@ def test_weighted_monthly_averages_with_masked_data(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_daily_averages(self):
         ds = self.ds.copy()
@@ -967,7 +967,7 @@ def test_weighted_daily_averages(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_hourly_averages(self):
         ds = self.ds.copy()
@@ -1011,7 +1011,7 @@ def test_weighted_hourly_averages(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

 class TestClimatology:
@@ -1105,7 +1105,7 @@ def test_subsets_climatology_based_on_reference_period(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_seasonal_climatology_with_DJF(self):
         ds = self.ds.copy()
@@ -1159,7 +1159,7 @@ def test_weighted_seasonal_climatology_with_DJF(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     @requires_dask
     def test_chunked_weighted_seasonal_climatology_with_DJF(self):
@@ -1214,7 +1214,7 @@ def test_chunked_weighted_seasonal_climatology_with_DJF(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_seasonal_climatology_with_JFD(self):
         ds = self.ds.copy()
@@ -1265,7 +1265,7 @@ def test_weighted_seasonal_climatology_with_JFD(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_custom_seasonal_climatology(self):
         ds = self.ds.copy()
@@ -1328,7 +1328,7 @@ def test_weighted_custom_seasonal_climatology(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_monthly_climatology(self):
         result = self.ds.temporal.climatology("ts", "month")
@@ -1391,7 +1391,7 @@ def test_weighted_monthly_climatology(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_unweighted_monthly_climatology(self):
         result = self.ds.temporal.climatology("ts", "month", weighted=False)
@@ -1453,7 +1453,7 @@ def test_unweighted_monthly_climatology(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_daily_climatology(self):
         result = self.ds.temporal.climatology("ts", "day", weighted=True)
@@ -1515,7 +1515,7 @@ def test_weighted_daily_climatology(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_daily_climatology_drops_leap_days_with_matching_calendar(self):
         time = xr.DataArray(
@@ -1606,7 +1606,7 @@ def test_weighted_daily_climatology_drops_leap_days_with_matching_calendar(self)
                 },
             )

-            assert result.identical(expected)
+            xr.testing.assert_identical(result, expected)

     def test_unweighted_daily_climatology(self):
         result = self.ds.temporal.climatology("ts", "day", weighted=False)
@@ -1668,7 +1668,7 @@ def test_unweighted_daily_climatology(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

 class TestDepartures:
@@ -1810,7 +1810,7 @@ def test_seasonal_departures_relative_to_climatology_reference_period(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_monthly_departures_relative_to_climatology_reference_period_with_same_output_freq(
         self,
@@ -1895,7 +1895,7 @@ def test_monthly_departures_relative_to_climatology_reference_period_with_same_o
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_seasonal_departures_with_DJF(self):
         ds = self.ds.copy()
@@ -1945,7 +1945,7 @@ def test_weighted_seasonal_departures_with_DJF(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_seasonal_departures_with_DJF_and_keep_weights(self):
         ds = self.ds.copy()
@@ -2021,7 +2021,7 @@ def test_weighted_seasonal_departures_with_DJF_and_keep_weights(self):
             dims=["time_original"],
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_unweighted_seasonal_departures_with_DJF(self):
         ds = self.ds.copy()
@@ -2071,7 +2071,7 @@ def test_unweighted_seasonal_departures_with_DJF(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_unweighted_seasonal_departures_with_JFD(self):
         ds = self.ds.copy()
@@ -2121,7 +2121,7 @@ def test_unweighted_seasonal_departures_with_JFD(self):
             },
         )

-        assert result.identical(expected)
+        xr.testing.assert_identical(result, expected)

     def test_weighted_daily_departures_drops_leap_days_with_matching_calendar(self):
         time = xr.DataArray(
@@ -2214,7 +2214,27 @@ def test_weighted_daily_departures_drops_leap_days_with_matching_calendar(self):
                 },
             )

-            assert result.identical(expected)
+            xr.testing.assert_identical(result, expected)
+
+
+def _check_each_weight_group_adds_up_to_1(ds: xr.Dataset, weights: xr.DataArray):
+    """Check that the sum of the weights in each group adds up to 1.0 (or 100%).
+
+    Parameters
+    ----------
+    ds : xr.Dataset
+        The dataset with the temporal accessor class attached.
+    weights : xr.DataArray
+        The weights to check, produced by the `_get_weights` method.
+    """
+    time_lengths = ds.time_bnds[:, 1] - ds.time_bnds[:, 0]
+    time_lengths = time_lengths.astype(np.float64)
+
+    grouped_time_lengths = ds.temporal._group_data(time_lengths)
+
+    actual_sum = ds.temporal._group_data(weights).sum().values
+    expected_sum = np.ones(len(grouped_time_lengths.groups))
+    np.testing.assert_allclose(actual_sum, expected_sum)

 class Test_GetWeights:
@@ -2289,6 +2309,8 @@ def test_weights_for_yearly_averages(self):
             )
             assert np.allclose(result, expected)

+            _check_each_weight_group_adds_up_to_1(ds, result)
+
         def test_weights_for_monthly_averages(self):
             ds = self.ds.copy()

@@ -2352,6 +2374,8 @@ def test_weights_for_monthly_averages(self):
             )
             assert np.allclose(result, expected)

+            _check_each_weight_group_adds_up_to_1(ds, result)
+
     class TestWeightsForGroupAverageMode:
         @pytest.fixture(autouse=True)
         def setup(self):
@@ -2423,6 +2447,8 @@ def test_weights_for_yearly_averages(self):
             )
             assert np.allclose(result, expected)

+            _check_each_weight_group_adds_up_to_1(ds, result)
+
         def test_weights_for_monthly_averages(self):
             ds = self.ds.copy()

@@ -2469,6 +2495,8 @@ def test_weights_for_monthly_averages(self):
             expected = np.ones(15)
             assert np.allclose(result, expected)

+            _check_each_weight_group_adds_up_to_1(ds, result)
+
         def test_weights_for_seasonal_averages_with_DJF_and_drop_incomplete_seasons(
             self,
         ):
@@ -2631,6 +2659,8 @@ def test_weights_for_seasonal_averages_with_DJF_and_drop_incomplete_seasons(
             )
             assert np.allclose(result, expected, equal_nan=True)

+            _check_each_weight_group_adds_up_to_1(ds, result)
+
         def test_weights_for_seasonal_averages_with_JFD(self):
             ds = self.ds.copy()

@@ -2702,6 +2732,8 @@ def test_weights_for_seasonal_averages_with_JFD(self):
             )
             assert np.allclose(result, expected)

+            _check_each_weight_group_adds_up_to_1(ds, result)
+
         def test_custom_season_time_series_weights(self):
             ds = self.ds.copy()

@@ -2780,6 +2812,8 @@ def test_custom_season_time_series_weights(self):
             )
             assert np.allclose(result, expected)

+            _check_each_weight_group_adds_up_to_1(ds, result)
+
         def test_weights_for_daily_averages(self):
             ds = self.ds.copy()

@@ -2873,6 +2907,8 @@ def test_weights_for_hourly_averages(self):
             expected = np.ones(15)
             assert np.allclose(result, expected)

+            _check_each_weight_group_adds_up_to_1(ds, result)
+
     class TestWeightsForClimatologyMode:
         @pytest.fixture(autouse=True)
         def setup(self):
@@ -3035,6 +3071,8 @@ def test_weights_for_seasonal_climatology_with_DJF(self):

             assert np.allclose(result, expected, equal_nan=True)

+            _check_each_weight_group_adds_up_to_1(ds, result)
+
         def test_weights_for_seasonal_climatology_with_JFD(self):
             ds = self.ds.copy()

@@ -3101,6 +3139,8 @@ def test_weights_for_seasonal_climatology_with_JFD(self):
             )
             assert np.allclose(result, expected, equal_nan=True)

+            _check_each_weight_group_adds_up_to_1(ds, result)
+
         def test_weights_for_annual_climatology(self):
             ds = self.ds.copy()

@@ -3165,6 +3205,8 @@ def test_weights_for_annual_climatology(self):
             )
             assert np.allclose(result, expected)

+            _check_each_weight_group_adds_up_to_1(ds, result)
+
         def test_weights_for_daily_climatology(self):
             ds = self.ds.copy()

@@ -3227,6 +3269,8 @@ def test_weights_for_daily_climatology(self):
             )
             assert np.allclose(result, expected)

+            _check_each_weight_group_adds_up_to_1(ds, result)
+

 class Test_Averager:
     # NOTE: This private method is tested because it is more redundant to

[Enhancement]: Temporal averaging performance
### Is your feature request related to a problem?

This may not be a high priority issue, but I think it is worthwhile to document here:
When refactoring e3sm_diags with xcdat, I was working on using temporal.climatology operation to get annual cycle of a data stream which has 1hourly data for 30 years (ntime = 262800) from 1 grid point. Using xcdat is slower than the native function from e3sm_diags. It is probably expected, because the xcdat code is more sophisticated in reading in, writing out the data. I just noticed that it seems any operations on xarray.dataarray is much slower than numpy arrays, so the native e3sm_diags climo function needs to be optimized accordingly to get better performance.

### Describe the solution you'd like

We can check if there are places where it is possible to get performance gain

### Describe alternatives you've considered

_No response_

### Additional context

Attaching example code and performance data.

```
### 1. Using temporal.climatology from xcdat
import xcdat as xc

file_path = '/global/cfs/cdirs/e3sm/e3sm_diags/postprocessed_e3sm_v2_data_for_e3sm_diags/20221103.v2.LR.amip.NGD_v3atm.chrysalis/arm-diags-data/PRECT_sgpc1_198501_201412.nc'
ds = xc.open_dataset(file_path)
ds_annual_cycle = ds.temporal.climatology("PRECT", "month")
#real    0m48.785s
#user    0m46.604s
#sys     0m12.709s

### 2. With external climo function from E3SM Diags
import xcdat as xc
from e3sm_diags.driver.utils.climo_xr import climo
file_path = '/global/cfs/cdirs/e3sm/e3sm_diags/postprocessed_e3sm_v2_data_for_e3sm_diags/20221103.v2.LR.amip.NGD_v3atm.chrysalis/arm-diags-data/PRECT_sgpc1_198501_201412.nc'
ds = xc.open_dataset(file_path)
ds_annual_cycle = climo(ds, "PRECT", "ANNUALCYCLE")

#real    0m23.995s
#user    0m19.630s
#sys     0m16.825s

### 3. Similar implementation but with cdat
import cdms2
from e3sm_diags.driver.utils.climo import climo
file_path = '/global/cfs/cdirs/e3sm/e3sm_diags/postprocessed_e3sm_v2_data_for_e3sm_diags/20221103.v2.LR.amip.NGD_v3atm.chrysalis/arm-diags-data/PRECT_sgpc1_198501_201412.nc'
ds = cdms2.open(file_path)
ds_annual_cycle = climo(ds("PRECT"), "ANNUALCYCLE")

#real    0m8.332s
#user    0m5.654s
#sys     0m11.613s

###4. Use cdat and cdutil for climatology
import cdms2
import cdutil

from e3sm_diags.driver.utils.climo import climo
file_path = '/global/cfs/cdirs/e3sm/e3sm_diags/postprocessed_e3sm_v2_data_for_e3sm_diags/20221103.v2.LR.amip.NGD_v3atm.chrysalis/arm-diags-data/PRECT_sgpc1_198501_201412.nc'ds = cdms2.open(file_path)
ds_annual_cycle = cdutil.ANNUALCYCLE.climatology(ds("PRECT"))

#real    3m40.007s
#user    3m34.030s
#sys     0m12.117s

```


diff --git a/doc/changelog.d/3705.added.md b/doc/changelog.d/3705.added.md
new file mode 100644
index 0000000000..9bc4540620
--- /dev/null
+++ b/doc/changelog.d/3705.added.md
@@ -0,0 +1,1 @@
+feat: speed up `requires_package` using caching
\ No newline at end of file
diff --git a/src/ansys/mapdl/core/misc.py b/src/ansys/mapdl/core/misc.py
index 63b2e35b61..e9bdc3cce4 100644
--- a/src/ansys/mapdl/core/misc.py
+++ b/src/ansys/mapdl/core/misc.py
@@ -22,7 +22,7 @@

 """Module for miscellaneous functions and methods"""
 from enum import Enum
-from functools import wraps
+from functools import cache, wraps
 import importlib
 import inspect
 import os
@@ -415,6 +415,16 @@ def write_array(filename: Union[str, bytes], array: np.ndarray) -> None:
     np.savetxt(filename, array, fmt="%20.12f")

+@cache
+def is_package_installed_cached(package_name):
+    try:
+        importlib.import_module(package_name)
+        return True
+
+    except ModuleNotFoundError:
+        return False
+
+
 def requires_package(package_name: str, softerror: bool = False) -> Callable:
     """
     Decorator check whether a package is installed or not.
@@ -430,11 +440,11 @@ def requires_package(package_name: str, softerror: bool = False) -> Callable:
     def decorator(function):
         @wraps(function)
         def wrapper(self, *args, **kwargs):
-            try:
-                importlib.import_module(package_name)
+
+            if is_package_installed_cached(package_name):
                 return function(self, *args, **kwargs)

-            except ModuleNotFoundError:
+            else:
                 msg = (
                     f"To use the method '{function.__name__}', "
                     f"the package '{package_name}' is required.\n"

diff --git a/tests/common.py b/tests/common.py
index 142e0a681a..9a7581f843 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -26,6 +26,7 @@
 import subprocess
 import time
 from typing import Dict, List
+from warnings import warn

 import psutil

diff --git a/tests/test_mapdl.py b/tests/test_mapdl.py
index 7d902e099a..5621b8dcf5 100644
--- a/tests/test_mapdl.py
+++ b/tests/test_mapdl.py
@@ -2836,3 +2836,14 @@ def test_none_on_selecting(mapdl, cleared, func):

     assert len(selfunc("all")) > 0
     assert len(selfunc(None)) == 0
+
+
+def test_requires_package_speed():
+    from ansys.mapdl.core.misc import requires_package
+
+    @requires_package("pyvista")
+    def my_func(i):
+        return i + 1
+
+    for i in range(1_000_000):
+        my_func(i)

`requires_package` is very slow
              It seems that `requires_package` is very slow:

![image](https://github.com/user-attachments/assets/186c6084-3765-4349-8205-81bdddae9c8e)

_Originally posted by @germa89 in https://github.com/ansys/pymapdl/issues/3703#issuecomment-2615615323_


diff --git a/crosstl/translator/lexer.py b/crosstl/translator/lexer.py
index e6540499..ac89f534 100644
--- a/crosstl/translator/lexer.py
+++ b/crosstl/translator/lexer.py
@@ -1,81 +1,82 @@
 import re
+from collections import OrderedDict

-TOKENS = [
-    ("COMMENT_SINGLE", r"//.*"),
-    ("COMMENT_MULTI", r"/\*[\s\S]*?\*/"),
-    ("SHADER", r"\bshader\b"),
-    ("VOID", r"\bvoid\b"),
-    ("STRUCT", r"\bstruct\b"),
-    ("CBUFFER", r"\bcbuffer\b"),
-    ("AT", r"\@"),
-    ("SAMPLER2D", r"\bsampler2D\b"),
-    ("SAMPLERCUBE", r"\bsamplerCube\b"),
-    ("VECTOR", r"\bvec[2-4]\b"),
-    ("MATRIX", r"\bmat[2-4]\b"),
-    ("BOOL", r"\bbool\b"),
-    ("VERTEX", r"\bvertex\b"),
-    ("FRAGMENT", r"\bfragment\b"),
-    ("FLOAT_NUMBER", r"\d*\.\d+|\d+\.\d*"),
-    ("FLOAT", r"\bfloat\b"),
-    ("INT", r"\bint\b"),
-    ("UINT", r"\buint\b"),
-    ("DOUBLE", r"\bdouble\b"),
-    ("SAMPLER", r"\bsampler\b"),
-    ("IDENTIFIER", r"[a-zA-Z_][a-zA-Z_0-9]*"),
-    ("ASSIGN_SHIFT_RIGHT", r">>="),
-    ("ASSIGN_SHIFT_LEFT", r"<<="),
-    ("NUMBER", r"\d+(\.\d+)?"),
-    ("LBRACE", r"\{"),
-    ("RBRACE", r"\}"),
-    ("LPAREN", r"\("),
-    ("RPAREN", r"\)"),
-    ("SEMICOLON", r";"),
-    ("COMMA", r","),
-    ("ASSIGN_ADD", r"\+="),
-    ("ASSIGN_SUB", r"-="),
-    ("ASSIGN_MUL", r"\*="),
-    ("ASSIGN_DIV", r"/="),
-    ("WHITESPACE", r"\s+"),
-    ("IF", r"\bif\b"),
-    ("ELSE", r"\belse\b"),
-    ("FOR", r"\bfor\b"),
-    ("RETURN", r"\breturn\b"),
-    ("BITWISE_SHIFT_LEFT", r"<<"),
-    ("BITWISE_SHIFT_RIGHT", r">>"),
-    ("LESS_EQUAL", r"<="),
-    ("GREATER_EQUAL", r">="),
-    ("GREATER_THAN", r">"),
-    ("LESS_THAN", r"<"),
-    ("INCREMENT", r"\+\+"),
-    ("DECREMENT", r"--"),
-    ("EQUAL", r"=="),
-    ("NOT_EQUAL", r"!="),
-    ("ASSIGN_AND", r"&="),
-    ("ASSIGN_OR", r"\|="),
-    ("ASSIGN_XOR", r"\^="),
-    ("LOGICAL_AND", r"&&"),
-    ("LOGICAL_OR", r"\|\|"),
-    ("NOT", r"!"),
-    ("ASSIGN_MOD", r"%="),
-    ("MOD", r"%"),
-    ("INCREMENT", r"\+\+"),
-    ("DECREMENT", r"\-\-"),
-    ("PLUS", r"\+"),
-    ("MINUS", r"-"),
-    ("MULTIPLY", r"\*"),
-    ("DIVIDE", r"/"),
-    ("DOT", r"\."),
-    ("EQUALS", r"="),
-    ("QUESTION", r"\?"),
-    ("COLON", r":"),
-    ("CONST", r"\bconst\b"),
-    ("BITWISE_AND", r"&"),
-    ("BITWISE_OR", r"\|"),
-    ("BITWISE_XOR", r"\^"),
-    ("BITWISE_NOT", r"~"),
-    ("shift_left", r"<<"),
-    ("shift_right", r">>"),
-]
+TOKENS = OrderedDict(
+    [
+        ("COMMENT_SINGLE", r"//.*"),
+        ("COMMENT_MULTI", r"/\*[\s\S]*?\*/"),
+        ("SHADER", r"\bshader\b"),
+        ("VOID", r"\bvoid\b"),
+        ("STRUCT", r"\bstruct\b"),
+        ("CBUFFER", r"\bcbuffer\b"),
+        ("AT", r"\@"),
+        ("SAMPLER2D", r"\bsampler2D\b"),
+        ("SAMPLERCUBE", r"\bsamplerCube\b"),
+        ("VECTOR", r"\bvec[2-4]\b"),
+        ("MATRIX", r"\bmat[2-4]\b"),
+        ("BOOL", r"\bbool\b"),
+        ("VERTEX", r"\bvertex\b"),
+        ("FRAGMENT", r"\bfragment\b"),
+        ("FLOAT_NUMBER", r"\d*\.\d+|\d+\.\d*"),
+        ("FLOAT", r"\bfloat\b"),
+        ("INT", r"\bint\b"),
+        ("UINT", r"\buint\b"),
+        ("DOUBLE", r"\bdouble\b"),
+        ("SAMPLER", r"\bsampler\b"),
+        ("IDENTIFIER", r"[a-zA-Z_][a-zA-Z_0-9]*"),
+        ("ASSIGN_SHIFT_RIGHT", r">>="),
+        ("ASSIGN_SHIFT_LEFT", r"<<="),
+        ("NUMBER", r"\d+(\.\d+)?"),
+        ("LBRACE", r"\{"),
+        ("RBRACE", r"\}"),
+        ("LPAREN", r"\("),
+        ("RPAREN", r"\)"),
+        ("SEMICOLON", r";"),
+        ("COMMA", r","),
+        ("ASSIGN_ADD", r"\+="),
+        ("ASSIGN_SUB", r"-="),
+        ("ASSIGN_MUL", r"\*="),
+        ("ASSIGN_DIV", r"/="),
+        ("WHITESPACE", r"\s+"),
+        ("IF", r"\bif\b"),
+        ("ELSE", r"\belse\b"),
+        ("FOR", r"\bfor\b"),
+        ("RETURN", r"\breturn\b"),
+        ("BITWISE_SHIFT_LEFT", r"<<"),
+        ("BITWISE_SHIFT_RIGHT", r">>"),
+        ("LESS_EQUAL", r"<="),
+        ("GREATER_EQUAL", r">="),
+        ("GREATER_THAN", r">"),
+        ("LESS_THAN", r"<"),
+        ("INCREMENT", r"\+\+"),
+        ("DECREMENT", r"--"),
+        ("EQUAL", r"=="),
+        ("NOT_EQUAL", r"!="),
+        ("ASSIGN_AND", r"&="),
+        ("ASSIGN_OR", r"\|="),
+        ("ASSIGN_XOR", r"\^="),
+        ("LOGICAL_AND", r"&&"),
+        ("LOGICAL_OR", r"\|\|"),
+        ("NOT", r"!"),
+        ("ASSIGN_MOD", r"%="),
+        ("MOD", r"%"),
+        ("INCREMENT", r"\+\+"),
+        ("DECREMENT", r"\-\-"),
+        ("PLUS", r"\+"),
+        ("MINUS", r"-"),
+        ("MULTIPLY", r"\*"),
+        ("DIVIDE", r"/"),
+        ("DOT", r"\."),
+        ("EQUALS", r"="),
+        ("QUESTION", r"\?"),
+        ("COLON", r":"),
+        ("CONST", r"\bconst\b"),
+        ("BITWISE_AND", r"&"),
+        ("BITWISE_OR", r"\|"),
+        ("BITWISE_XOR", r"\^"),
+        ("BITWISE_NOT", r"~"),
+    ]
+)

 KEYWORDS = {
     "shader": "SHADER",
@@ -91,46 +92,61 @@

 class Lexer:
-    """A simple lexer for the shader language
+    """A simple lexer for the shader language with optimizations

     This lexer tokenizes the input code into a list of tokens.
+    Includes optimizations:
+    - Token caching for frequently used tokens
+    - Combined regex patterns for similar tokens
+    - Precompiled regex patterns

     Attributes:
         code (str): The input code to tokenize
         tokens (list): A list of tokens generated from the input code
-
+        token_cache (dict): Cache for frequently used tokens
     """

     def __init__(self, code):
         self.code = code
         self.tokens = []
+        self.token_cache = {}
+        self.regex_cache = self._compile_patterns()
         self.tokenize()

-    def tokenize(self):
-        pos = 0
-        while pos < len(self.code):
-            match = None
-            for token_type, pattern in TOKENS:
-                regex = re.compile(pattern)
-                match = regex.match(self.code, pos)
-                if match:
-                    text = match.group(0)
-                    if token_type == "IDENTIFIER" and text in KEYWORDS:
-                        token_type = KEYWORDS[text]
-                    if token_type != "WHITESPACE":  # Ignore whitespace tokens
+    def _compile_patterns(self):
+        """Compile a single regex with named groups for all tokens."""
+        combined_pattern = "|".join(
+            f"(?P<{name}>{pattern})" for name, pattern in TOKENS.items()
+        )
+        return re.compile(combined_pattern)

-                        token = (token_type, text)
+    def _get_cached_token(self, text, token_type):
+        """Return a cached token tuple if possible."""
+        cache_key = (text, token_type)
+        if cache_key not in self.token_cache:
+            self.token_cache[cache_key] = (token_type, text)
+        return self.token_cache[cache_key]

-                        self.tokens.append(token)
-                    pos = match.end(0)
-                    break
-            if not match:
-                unmatched_char = self.code[pos]
-                highlighted_code = (
-                    self.code[:pos] + "[" + self.code[pos] + "]" + self.code[pos + 1 :]
+    def tokenize(self):
+        pos = 0
+        length = len(self.code)
+        while pos < length:
+            match = self.regex_cache.match(self.code, pos)
+            if match:
+                token_type = match.lastgroup
+                text = match.group(token_type)
+                if token_type == "IDENTIFIER" and text in KEYWORDS:
+                    token_type = KEYWORDS[text]
+                if token_type != "WHITESPACE":
+                    token = self._get_cached_token(text, token_type)
+                    self.tokens.append(token)
+                pos = match.end(0)
+            else:
+                bad_char = self.code[pos]
+                highlighted = (
+                    self.code[:pos] + "[" + bad_char + "]" + self.code[pos + 1 :]
                 )
                 raise SyntaxError(
-                    f"Illegal character '{unmatched_char}' at position {pos}\n{highlighted_code}"
+                    f"Illegal character '{bad_char}' at position {pos}\n{highlighted}"
                 )
-
-        self.tokens.append(("EOF", None))  # End of file token
+        self.tokens.append(self._get_cached_token(None, "EOF"))

diff --git a/tests/test_translator/test_lexer.py b/tests/test_translator/test_lexer.py
index abff042f..35c778a1 100644
--- a/tests/test_translator/test_lexer.py
+++ b/tests/test_translator/test_lexer.py
@@ -1,6 +1,6 @@
-from crosstl.translator.lexer import Lexer
 import pytest
 from typing import List
+from crosstl.translator.lexer import Lexer

 def tokenize_code(code: str) -> List:
@@ -21,7 +21,8 @@ def test_struct_tokenization():

     """
     try:
-        tokenize_code(code)
+        tokens = tokenize_code(code)
+        assert any(t[0] == "STRUCT" for t in tokens), "Missing 'STRUCT' token"
     except SyntaxError:
         pytest.fail("Struct tokenization not implemented.")

@@ -35,7 +36,9 @@ def test_if_statement_tokenization():
     }
     """
     try:
-        tokenize_code(code)
+        tokens = tokenize_code(code)
+        assert any(t[0] == "IF" for t in tokens), "Missing 'IF' token"
+        assert any(t[0] == "ELSE" for t in tokens), "Missing 'ELSE' token"
     except SyntaxError:
         pytest.fail("if tokenization not implemented.")

@@ -47,7 +50,8 @@ def test_for_statement_tokenization():
     }
     """
     try:
-        tokenize_code(code)
+        tokens = tokenize_code(code)
+        assert any(t[0] == "FOR" for t in tokens), "Missing 'FOR' token"
     except SyntaxError:
         pytest.fail("for tokenization not implemented.")

@@ -61,7 +65,8 @@ def test_else_statement_tokenization():
     }
     """
     try:
-        tokenize_code(code)
+        tokens = tokenize_code(code)
+        assert any(t[0] == "ELSE" for t in tokens), "Missing 'ELSE' token"
     except SyntaxError:
         pytest.fail("else tokenization not implemented.")

@@ -82,9 +87,10 @@ def test_else_if_statement_tokenization():
     }
     """
     try:
-        tokenize_code(code)
+        tokens = tokenize_code(code)
+        assert tokens, "No tokens generated"
     except SyntaxError:
-        pytest.fail("else if  tokenization not implemented.")
+        pytest.fail("else if tokenization not implemented.")

 def test_function_call_tokenization():
@@ -98,11 +104,9 @@ def test_function_call_tokenization():

     fragment {
         vec4 main(VSOutput input) @ gl_FragColor {
-            // Sample brightness and calculate bloom
             float brightness = texture(iChannel0, input.color.xy).r;
             float bloom = max(0.0, brightness - 0.5);
             bloom = perlinNoise(input.color.xy);
-            // Apply bloom to the texture color
             vec3 texColor = texture(iChannel0, input.color.xy).rgb;
             vec3 colorWithBloom = texColor + vec3(bloom);

@@ -112,7 +116,9 @@ def test_function_call_tokenization():
 }
 """
     try:
-        tokenize_code(code)
+        tokens = tokenize_code(code)
+        assert any(t[0] == "SHADER" for t in tokens), "Missing 'SHADER' token"
+        assert any(t[0] == "FRAGMENT" for t in tokens), "Missing 'FRAGMENT' token"
     except SyntaxError:
         pytest.fail("Function call tokenization not implemented.")

@@ -122,15 +128,19 @@ def test_bitwise_operator_tokenization():
     int a = 60; // 60 = 0011 1100
     int b = 13; // 13 = 0000 1101
     int c = 0;
-    c = a & b; // 12 = 0000 1100
-    c = a | b; // 61 = 0011 1101
-    c = a ^ b; // 49 = 0011 0001
-    c = ~a; // -61 = 1100 0011
-    c = a << 2; // 240 = 1111 0000
-    c = a >> 2; // 15 = 0000 1111
-      """
+    c = a & b;
+    c = a | b;
+    c = a ^ b;
+    c = ~a;
+    c = a << 2;
+    c = a >> 2;
+    """
     try:
-        tokenize_code(code)
+        tokens = tokenize_code(code)
+        assert any(t[0] == "BITWISE_AND" for t in tokens), "Missing '&' token"
+        assert any(t[0] == "BITWISE_OR" for t in tokens), "Missing '|' token"
+        assert any(t[0] == "BITWISE_XOR" for t in tokens), "Missing '^' token"
+        assert any(t[0] == "BITWISE_NOT" for t in tokens), "Missing '~' token"
     except SyntaxError:
         pytest.fail("Bitwise operator tokenization not implemented.")

@@ -144,7 +154,12 @@ def test_data_types_tokenization():
     bool e;
     """
     try:
-        tokenize_code(code)
+        tokens = tokenize_code(code)
+        assert any(t[0] == "INT" for t in tokens), "Missing 'INT' token"
+        assert any(t[0] == "UINT" for t in tokens), "Missing 'UINT' token"
+        assert any(t[0] == "FLOAT" for t in tokens), "Missing 'FLOAT' token"
+        assert any(t[0] == "DOUBLE" for t in tokens), "Missing 'DOUBLE' token"
+        assert any(t[0] == "BOOL" for t in tokens), "Missing 'BOOL' token"
     except SyntaxError:
         pytest.fail("Data types tokenization not implemented.")

@@ -153,13 +168,18 @@ def test_operators_tokenization():
     code = """
     int a;
     a = 2 + 1;
-    a = a -  2;
+    a = a - 2;
     a = a / 1;
     a = a * 2;
     a = a % 2;
     """
     try:
-        tokenize_code(code)
+        tokens = tokenize_code(code)
+        assert any(t[0] == "PLUS" for t in tokens), "Missing '+' token"
+        assert any(t[0] == "MINUS" for t in tokens), "Missing '-' token"
+        assert any(t[0] == "DIVIDE" for t in tokens), "Missing '/' token"
+        assert any(t[0] == "MULTIPLY" for t in tokens), "Missing '*' token"
+        assert any(t[0] == "MOD" for t in tokens), "Missing '%' token"
     except SyntaxError:
         pytest.fail("Operators tokenization not implemented.")

@@ -173,7 +193,9 @@ def test_logical_operators_tokenization():
     }
     """
     try:
-        tokenize_code(code)
+        tokens = tokenize_code(code)
+        assert any(t[0] == "LOGICAL_OR" for t in tokens), "Missing '||' token"
+        assert any(t[0] == "LOGICAL_AND" for t in tokens), "Missing '&&' token"
     except SyntaxError:
         pytest.fail("Logical Operators tokenization not implemented.")

@@ -182,10 +204,11 @@ def test_assignment_shift_operators():
     code = """
     a >>= 1;
     b <<= 1;
-        """
-
+    """
     try:
-        tokenize_code(code)
+        tokens = tokenize_code(code)
+        assert any(t[0] == "ASSIGN_SHIFT_RIGHT" for t in tokens), "Missing '>>=' token"
+        assert any(t[0] == "ASSIGN_SHIFT_LEFT" for t in tokens), "Missing '<<=' token"
     except SyntaxError:
         pytest.fail("Shift operators tokenization failed.")

@@ -203,7 +226,15 @@ def test_assignment_operators_tokenization():
     a ^= 1;
     """
     try:
-        tokenize_code(code)
+        tokens = tokenize_code(code)
+        assert any(t[0] == "ASSIGN_ADD" for t in tokens), "Missing '+=' token"
+        assert any(t[0] == "ASSIGN_MUL" for t in tokens), "Missing '*=' token"
+        assert any(t[0] == "ASSIGN_DIV" for t in tokens), "Missing '/=' token"
+        assert any(t[0] == "ASSIGN_SUB" for t in tokens), "Missing '-=' token"
+        assert any(t[0] == "ASSIGN_MOD" for t in tokens), "Missing '%=' token"
+        assert any(t[0] == "ASSIGN_AND" for t in tokens), "Missing '&=' token"
+        assert any(t[0] == "ASSIGN_OR" for t in tokens), "Missing '|=' token"
+        assert any(t[0] == "ASSIGN_XOR" for t in tokens), "Missing '^=' token"
     except SyntaxError:
         pytest.fail("Assignment operators tokenization not implemented.")

@@ -212,12 +243,18 @@ def test_const_tokenization():
     code = """
     const int a;
     """
-
     try:
-        tokenize_code(code)
+        tokens = tokenize_code(code)
+        assert any(t[0] == "CONST" for t in tokens), "Missing 'CONST' token"
     except SyntaxError:
         pytest.fail("Const keyword tokenization failed")

+def test_illegal_character():
+    code = "int a = 1 @#"
+    with pytest.raises(SyntaxError):
+        tokenize_code(code)
+
+
 if __name__ == "__main__":
     pytest.main()

Optimize Lexer Performance with Token Caching and Pattern Combining
## Description
The lexer can be optimized to improve performance and reduce memory usage. This task focuses on implementing token caching, combining similar regex patterns, and caching compiled patterns.

### Proposed Changes
1. **Token Caching**
   - Add a `token_cache` dictionary to reuse frequently used tokens.
   - Introduce a `_get_cached_token` helper method to manage the cache.

2. **Combined Regex Patterns**
   - Group similar patterns (e.g., numbers, vectors, matrices) into combined regex patterns.
   - Add a `COMBINED_PATTERNS` dictionary to simplify and speed up token matching.

3. **Pattern Precompilation**
   - Cache compiled regex patterns to avoid recompilation.
   - Add a `_compile_patterns` method for one-time regex compilation and store patterns in `REGEX_CACHE`.

## Files to Modify
[crosstl/translator/lexer.py](https://github.com/CrossGL/crosstl/blob/main/crosstl/translator/lexer.py)

---

@NripeshN, I would love to work on this task. Let me know if it sounds good!

<!-- BOT_STATE: {"assignee": "Swish78", "assigned_at": "2024-12-26T13:56:30.681969+00:00", "reminder_sent": false, "unassigned": false} -->

@Swish78 can you assign me this task I think I can complete this

@NripeshN Hii I would like to work on this issue
To assign issues to yourself please read this documentation: https://github.com/CrossGL/crosstl/blob/main/CONTRIBUTING.md#-assigning-or-creating-issues
@CrossGL-issue-bot assign me
@CrossGL-issue-bot assign me


diff --git a/app/api/tfl_api.py b/app/api/tfl_api.py
index 232b626..b688263 100644
--- a/app/api/tfl_api.py
+++ b/app/api/tfl_api.py
@@ -1,4 +1,5 @@
 import requests
+import requests_cache

 class TfLAPI:
     def __init__(self, api_key):
@@ -7,9 +8,16 @@ def __init__(self, api_key):

     def fetch_all_stop_points(self):
         """Fetch all stop points."""
+        session = requests_cache.CachedSession('stop_points_cache')
         url = f"{self.base_url}/StopPoint/Mode/tube?app_key={self.api_key}"
-        response = requests.get(url)

+        # Check if the response is in the cache
+        cached_response = session.cache.get_response(url)
+        if cached_response:
+            return cached_response.json()
+
+        # If not in cache, make the API call
+        response = session.get(url)
         if response.status_code == 200:
             return response.json()
         else:
diff --git a/app/ui/main_window.py b/app/ui/main_window.py
index 266b540..a1eb9e1 100644
--- a/app/ui/main_window.py
+++ b/app/ui/main_window.py
@@ -1,28 +1,33 @@
 from kivy.app import App
 from kivy.lang import Builder
+from kivy.uix.boxlayout import BoxLayout
 from ui.dropdown import Dropdown
+from api.tfl_api import TfLAPI
+from config import TFL_API_KEY

 class MainWindow(App):
     def build(self):
         # Load the KV file
         Builder.load_file("ui/main_window.kv")
+        main_layout = BoxLayout(orientation="vertical")
+
+        api = TfLAPI(TFL_API_KEY)
+        stop_points = api.fetch_all_stop_points()

         # Create the DropDown widget
         dropdown_widget = Dropdown()
+        dropdown_options = {}

-        # Define options as text-value pairs
-        dropdown_options = {
-            "Oxford": "1",
-            "Cambridge": "2",
-            "London": "3",
-            "Manchester": "4",
-        }
+        for stop_point in stop_points.get("stopPoints", []):
+            dropdown_options[stop_point.get("commonName")] = stop_point.get("id")

-        # Set the dropdown options dynamically
         dropdown_widget.dropdown_options = dropdown_options

-        return dropdown_widget
+        # Add the DropDown widget to the main layout
+        main_layout.add_widget(dropdown_widget)
+
+        return main_layout

 if __name__ == "__main__":
diff --git a/requirements.txt b/requirements.txt
index 4c8f79e..f473bcf 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -51,4 +51,5 @@ Werkzeug==3.1.3
 wsproto==1.2.0
 yarl==1.11.1
 requests-mock
-pytest
\ No newline at end of file
+pytest
+requests-cache
\ No newline at end of file

Cache Station List
### Summary
Currently the station list is refreshed everytime the program is launched, it would probably be best to cache this to improve performance of app and prevent being rate limited by API.

### Tasks
- Cache the list of stations
- Data stored should include ID, name, and location


diff --git a/workspace/dependabot/jobs.py b/workspace/dependabot/jobs.py
index ad92d5b4..602c491a 100644
--- a/workspace/dependabot/jobs.py
+++ b/workspace/dependabot/jobs.py
@@ -1,7 +1,7 @@
 from datetime import date, timedelta
 from itertools import cycle, islice

-from workspace.utils.people import TEAM_REX, People, get_formatted_slack_username
+from workspace.utils.people import TEAM_REX, People
 from workspace.utils.rota import RotaReporter

@@ -11,6 +11,7 @@ class DependabotRotaReporter(RotaReporter):
     The candidates are currently Team Rex (except Katie).
     If the candidate definition or Team changes this will affect
     the rota offset and the rota will restart at an arbitrary point.
+    Consider redesigning class to include an offset if that happens.
     """

     def get_rota(self) -> dict:
@@ -37,9 +38,9 @@ def get_rota_text_for_week(
     ) -> str:
         checker = rota[monday]
         if this_or_next == "this":
-            checker = get_formatted_slack_username(checker.value)
+            checker = checker.formatted_slack_username
         else:
-            checker = checker.name.title()
+            checker = checker.human_readable
         return f"To review dependabot PRs {this_or_next} week ({self.format_week(monday)}): {checker}"

diff --git a/workspace/report/generate_report.py b/workspace/report/generate_report.py
index d1d036ac..179ab890 100644
--- a/workspace/report/generate_report.py
+++ b/workspace/report/generate_report.py
@@ -5,10 +5,7 @@
 import requests

 from workspace.utils.blocks import get_basic_header_and_text_blocks
-from workspace.utils.people import (
-    get_formatted_slack_username,
-    get_person_from_github_username,
-)
+from workspace.utils.people import People

 URL = "https://api.github.com/graphql"
@@ -174,7 +171,7 @@ def get_status_and_summary(card):  # pragma: no cover
     title = card["content"]["title"]
     url = card["content"].get("bodyUrl")
     assignees = " / ".join(
-        get_formatted_slack_username(get_person_from_github_username(node["login"]))
+        People.by_github_username(node["login"]).formatted_slack_username
         for node in card["content"]["assignees"]["nodes"]
     )

diff --git a/workspace/standup/jobs.py b/workspace/standup/jobs.py
index 39ab7dd4..ba0144c3 100644
--- a/workspace/standup/jobs.py
+++ b/workspace/standup/jobs.py
@@ -3,12 +3,13 @@
 from datetime import date, datetime, timedelta

 from workspace.utils import blocks
+from workspace.utils.people import People

 PAIRS = {
-    "monday": ["Mike", "Jon"],
-    "wednesday": ["Mary", "Steve"],
-    "friday": ["Thomas", "Katie"],
+    "monday": [People.MIKE, People.JON],
+    "wednesday": [People.MARY, People.STEVE],
+    "friday": [People.THOMAS, People.KATIE],
 }

@@ -38,11 +39,12 @@ def weekly_rota(args):
     header = "Team Rex stand ups this week"
     days = "\n".join(
         [
-            f"{day_of_week.title()}: {PAIRS[day_of_week][primary]} (backup: {PAIRS[day_of_week][secondary]})"
+            f"{day_of_week.title()}: "
+            f"{PAIRS[day_of_week][primary].human_readable} "
+            f"(backup: {PAIRS[day_of_week][secondary].human_readable})"
             for day_of_week in PAIRS.keys()
         ]
     )
-
     return json.dumps(blocks.get_basic_header_and_text_blocks(header, days))

@@ -53,7 +55,11 @@ def daily_rota(args):
     primary = is_even_week(rota_date)
     secondary = 0 if primary else 1
     header = "Team Rex stand up"
-    body = f"{day_of_week.title()}: {PAIRS[day_of_week][primary]} (backup: {PAIRS[day_of_week][secondary]})"
+    body = (
+        f"{day_of_week.title()}: "
+        f"{PAIRS[day_of_week][primary].formatted_slack_username} "
+        f"(backup: {PAIRS[day_of_week][secondary].human_readable})"
+    )

     return json.dumps(blocks.get_basic_header_and_text_blocks(header, body))

diff --git a/workspace/utils/people.py b/workspace/utils/people.py
index b1aa3b2a..76009a7a 100644
--- a/workspace/utils/people.py
+++ b/workspace/utils/people.py
@@ -1,18 +1,56 @@
-from collections import namedtuple
-from enum import Enum
+from dataclasses import dataclass
+from typing import Optional

-Person = namedtuple("Person", ["github_username", "slack_username"])
+@dataclass(order=True)
+class Person:
+    github_username: str
+    slack_username: str
+    # Optional because this is mostly set in PersonCollection.__init__().
+    human_readable: Optional[str] = ""

+    @property
+    def formatted_slack_username(self) -> str:
+        return f"<@{self.slack_username}>"

-class People(Enum):
+
+class PersonCollection(type):
+    """Metaclass for collections of Person instances."""
+
+    def __init__(cls, name, bases, namespace):
+        # dict mapping github_username to People instances, for later lookups.
+        cls._by_github_username = {}
+
+        people_items = [
+            item for item in namespace.items() if isinstance(item[1], Person)
+        ]
+        for attribute_name, person in people_items:
+            cls._by_github_username[person.github_username] = person
+            # Populate human_readable based on attribute value if not set explicitly.
+            if not person.human_readable:
+                person.human_readable = attribute_name.title().replace("_", " ")
+
+        super().__init__(name, bases, namespace)
+
+    def __iter__(cls):
+        # Enable iteration by `for person in People` or similar to work.
+        return iter(
+            sorted(value for value in vars(cls).values() if isinstance(value, Person))
+        )
+
+
+class People(metaclass=PersonCollection):
     """Tech team members' GitHub and Slack usernames."""

-    # Find a Slack user's username by right-clicking on their name in the Slack app and clicking "Copy link".
+    # Find a Slack user's username by right-clicking on their name in the Slack
+    # app and clicking "Copy link".

+    # human_readable is mostly set by PersonCollection.__init__() but can be
+    # overriden as the third argument if required. This avoids the need to
+    # explicitly specify it for most people.
     ALICE = Person("alarthast", "U07KX6L3CMA")
     BECKY = Person("rebkwok", "U01SP5JLBFD")
-    BEN_BC = Person("benbc", "U01SPCP06Q1")
+    BEN_BC = Person("benbc", "U01SPCP06Q1", "Ben BC")
     CATHERINE = Person("CLStables", "U036A6LTR7D")
     DAVE = Person("evansd", "UAXE5V4RG")
     ELI = Person("eli-miriam", "U07LHEJ9TS4")
@@ -32,11 +70,20 @@ class People(Enum):
     TOM_P = Person("remlapmot", "U07L0L0SS6M")
     TOM_W = Person("madwort", "U019R5FJ7G8")

+    @classmethod
+    def by_github_username(cls, github_username):
+        default = Person(
+            human_readable=github_username,
+            github_username=github_username,
+            slack_username=github_username,
+        )
+        return cls._by_github_username.get(github_username, default)

-PEOPLE_BY_GITHUB_USERNAME = {
-    person.value.github_username: person.value for person in People
-}

+# Note that adding to or re-ordering this list will will affect the
+# DependabotRotaReporter ordering algorithm, restarting it at an arbitrary
+# point. If you need to change this list, consider redesigning that class to
+# include an offset.
 TEAM_REX = [
     People.JON,
     People.KATIE,
@@ -45,12 +92,3 @@ class People(Enum):
     People.MIKE,
     People.THOMAS,
 ]
-
-
-def get_person_from_github_username(github_username) -> Person:
-    default = Person(github_username=github_username, slack_username=github_username)
-    return PEOPLE_BY_GITHUB_USERNAME.get(github_username, default)
-
-
-def get_formatted_slack_username(person: Person) -> str:
-    return f"<@{person.slack_username}>"

diff --git a/tests/workspace/test_people.py b/tests/workspace/test_people.py
index db1b11f6..748591c9 100644
--- a/tests/workspace/test_people.py
+++ b/tests/workspace/test_people.py
@@ -1,24 +1,25 @@
-from workspace.utils.people import (
-    People,
-    get_formatted_slack_username,
-    get_person_from_github_username,
-)
+from workspace.utils.people import People

 def test_get_person_from_github_username():
-    result = get_person_from_github_username("lucyb")
+    result = People.by_github_username("lucyb")

-    assert result == People.LUCY.value
+    assert result == People.LUCY

 def test_get_person_from_github_username_returns_default():
-    result = get_person_from_github_username("TestUser")
+    result = People.by_github_username("TestUser")

+    assert result.human_readable == "TestUser"
     assert result.github_username == "TestUser"
     assert result.slack_username == "TestUser"

-def test_get_formatted_slack_username_returns_slack_user_id():
-    result = get_formatted_slack_username(People.LUCY.value)
+def test_formatted_slack_username():
+    result = People.LUCY.formatted_slack_username

     assert result == "<@U035FT48KEK>"
+
+
+def test_iteration():
+    assert isinstance([person for person in People], list)
diff --git a/tests/workspace/test_standup_rota.py b/tests/workspace/test_standup_rota.py
index e28d1b5c..61ffb627 100644
--- a/tests/workspace/test_standup_rota.py
+++ b/tests/workspace/test_standup_rota.py
@@ -58,7 +58,7 @@ def test_daily_rota_odd_week(freezer):
         },
         {
             "text": {
-                "text": "Monday: Jon (backup: Mike)",
+                "text": "Monday: <@U023ZG5H24R> (backup: Mike)",
                 "type": "mrkdwn",
             },
             "type": "section",
@@ -77,7 +77,7 @@ def test_daily_rota_even_week(freezer):
         },
         {
             "text": {
-                "text": "Wednesday: Mary (backup: Steve)",
+                "text": "Wednesday: <@U07LKQ06Q8L> (backup: Steve)",
                 "type": "mrkdwn",
             },
             "type": "section",

Refactor Team REX Standup Rota
#674  introduced the concept of non-spreadsheet based `RotaReporter`s and some improvements to how staff slack/Github usernames are handled. The Team REX standup rota could be refactored to use these new structures, for clarity and such that users are notified on Slack when they are on duty in the Rota.

Following on from #674, would like to also address if quick:
- re-name the dependabot rota as it's no longer using dependabot...
- add a note in `people.py` about the consequences of adding to or reordering `TEAM_REX`.


diff --git a/changes/6146.feature b/changes/6146.feature
new file mode 100644
index 00000000000..e40b88d474f
--- /dev/null
+++ b/changes/6146.feature
@@ -0,0 +1,1 @@
+Render snippets faster through better use of existing jinja2 tags. Use ``{% snippet 'path/to/snippet.html', arg1=test %}`` instead of ``{{ h.snippet('path/to/snippet.html', arg1=test) }}`` in templates for better performance.
diff --git a/ckan/config/middleware/flask_app.py b/ckan/config/middleware/flask_app.py
index 32825bb6896..5b59d0016de 100644
--- a/ckan/config/middleware/flask_app.py
+++ b/ckan/config/middleware/flask_app.py
@@ -21,7 +21,6 @@
     Unauthorized,
     Forbidden
 )
-from werkzeug.local import LocalProxy

 from flask_babel import Babel

@@ -99,14 +98,6 @@ def __call__(self, environ: Any, start_response: Any):
         return self.app(environ, start_response)

-def _ungettext_alias():
-    u'''
-    Provide `ungettext` as an alias of `ngettext` for backwards
-    compatibility
-    '''
-    return dict(ungettext=ungettext)
-
-
 def make_flask_stack(conf: Union[Config, CKANConfig]) -> CKANApp:
     """ This has to pass the flask app through all the same middleware that
     Pylons used """
@@ -182,6 +173,15 @@ def make_flask_stack(conf: Union[Config, CKANConfig]) -> CKANApp:
     app.jinja_env.filters['empty_and_escape'] = \
         jinja_extensions.empty_and_escape

+    # globals work in imported and included templates (like snippets)
+    # whereas context processors do not
+    app.jinja_env.globals.update({
+        'h': h.helper_functions,
+        'ungettext': ungettext,
+        'current_user': current_user,
+        'c': g,  # backwards compat. with old Pylons templates
+    })
+
     # Common handlers for all requests
     #
     # flask types do not mention that it's possible to return a response from
@@ -189,12 +189,6 @@ def make_flask_stack(conf: Union[Config, CKANConfig]) -> CKANApp:
     app.before_request(ckan_before_request)
     app.after_request(ckan_after_request)

-    # Template context processors
-    app.context_processor(helper_functions)
-    app.context_processor(c_object)
-
-    app.context_processor(_ungettext_alias)
-
     # Babel
     _ckan_i18n_dir = i18n.get_ckan_i18n_dir()

@@ -408,20 +402,6 @@ def ckan_after_request(response: Response) -> Response:
     return response

-def helper_functions() -> dict[str, h.HelperAttributeDict]:
-    u'''Make helper functions (`h`) available to Flask templates'''
-    if not h.helper_functions:
-        h.load_plugin_helpers()
-    return dict(h=h.helper_functions)
-
-
-def c_object() -> dict[str, LocalProxy[Any]]:
-    u'''
-    Expose `c` as an alias of `g` in templates for backwards compatibility
-    '''
-    return dict(c=g)
-
-
 class CKAN_AppCtxGlobals(_AppCtxGlobals):  # noqa

     '''Custom Flask AppCtxGlobal class (flask.g).'''
diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py
index a2af1644638..14a9ba2665a 100644
--- a/ckan/lib/helpers.py
+++ b/ckan/lib/helpers.py
@@ -1746,8 +1746,9 @@ def dump_json(obj: Any, **kw: Any) -> str:

 @core_helper
 def snippet(template_name: str, **kw: Any) -> str:
-    ''' This function is used to load html snippets into pages. keywords
-    can be used to pass parameters into the snippet rendering '''
+    '''
+    Use {% snippet %} tag instead for better performance.
+    '''
     import ckan.lib.base as base
     return base.render_snippet(template_name, **kw)

diff --git a/ckan/lib/jinja_extensions.py b/ckan/lib/jinja_extensions.py
index ce2608f1e30..a17b1504a53 100644
--- a/ckan/lib/jinja_extensions.py
+++ b/ckan/lib/jinja_extensions.py
@@ -9,11 +9,11 @@
 from jinja2 import nodes
 from jinja2 import loaders
 from jinja2 import ext
+from jinja2.parser import Parser
 from jinja2.exceptions import TemplateNotFound
 from jinja2.utils import open_if_exists
 from markupsafe import escape

-import ckan.lib.base as base
 import ckan.lib.helpers as h
 from ckan.common import config
 from markupsafe import Markup
@@ -197,6 +197,18 @@ def get_source(self, environment: Any, template: str) -> Any:
             searchpaths = self.searchpath[index + 1:]
         else:
             searchpaths = self.searchpath
+        # § snippet wrapper
+        smatch = re.match(r'([^"]+)§(\w+(?:,\w+)*)?([.]\w+)$', template)
+        if smatch:
+            # check for TemplateNotFound on real template
+            args = ',' + smatch[2] if smatch[2] else ''
+            return (
+                f'{{% macro snippet(_template{args}) %}}'
+                f'{{% include _template %}}'
+                f'{{% endmacro %}}',
+                template,
+                lambda: True
+            )
         # end of ckan changes
         pieces = loaders.split_template_path(template)
         for searchpath in searchpaths:
@@ -261,20 +273,61 @@ def make_call_node(*kw: Any) -> Any:
         return nodes.Output([make_call_node()]).set_lineno(tag.lineno)

-class SnippetExtension(BaseExtension):
+class SnippetExtension(ext.Extension):
     ''' Custom snippet tag

     {% snippet <template_name> [, <fallback_template_name>]...
                [, <keyword>=<value>]... %}

-    see lib.helpers.snippet() for more details.
+    renders <template_name> with <keyword>=<value> as local variables
     '''

-    tags = set(['snippet'])
+    tags = {'snippet'}
+
+    def parse(self, parser: Parser):
+        lineno = next(parser.stream).lineno
+        templates: list[nodes.Expr] = []
+        targets: list[nodes.Name] = []
+        kwargs: list[nodes.Keyword] = []
+        while not parser.stream.current.test_any('block_end'):
+            if templates or targets:
+                parser.stream.expect('comma')
+            if parser.stream.current.test('name') and parser.stream.look().test('assign'):
+                key = parser.parse_assign_target()
+                key.set_ctx('param')
+                targets.append(key)
+                parser.stream.expect('assign')
+                value = parser.parse_expression()
+                kwargs.append(nodes.Keyword(key.name, value, lineno=lineno))
+            else:
+                templates.append(parser.parse_expression())
+
+        imp = nodes.FromImport(lineno=lineno)
+        args = '§' + ','.join(targ.name for targ in targets)
+        template = 'dynamic' + args + '.html'
+        if isinstance(templates[0], nodes.Const):
+            template = args.join(path.splitext(templates[0].value))
+        imp.template = nodes.Const(template)
+        imp.names = ['snippet']
+        imp.with_context = False
+        nam = nodes.Name('snippet', 'load', lineno=lineno)
+        call = nodes.Call(
+            nam,
+            [nodes.List(templates, lineno=lineno)],
+            kwargs,
+            None,
+            None,
+            lineno=lineno
+        )
+        call.node = nam
+        out = nodes.Output(lineno=lineno)
+        out.nodes = [call]
+        wit = nodes.With(lineno=lineno)
+        wit.targets = []
+        wit.values = []
+        wit.body = [imp, out]
+        return wit

-    @classmethod
-    def _call(cls, args: Iterable[Any], kwargs: dict[str, Any]):
-        return base.render_snippet(*args, **kwargs)

 class UrlForStaticExtension(BaseExtension):
     ''' Custom url_for_static tag for getting a path for static assets.
diff --git a/ckan/templates/group/read.html b/ckan/templates/group/read.html
index a8022a1fefd..573b4af6739 100644
--- a/ckan/templates/group/read.html
+++ b/ckan/templates/group/read.html
@@ -21,7 +21,7 @@
   {% endblock %}
   {% block packages_list %}
     {% if page.items %}
-      {{ h.snippet('snippets/package_list.html', packages=page.items) }}
+      {% snippet 'snippets/package_list.html', packages=page.items %}
     {% endif %}
   {% endblock %}
   {% block page_pagination %}
@@ -34,7 +34,7 @@

       {% for facet in facet_titles %}
-        {{ h.snippet('snippets/facet_list.html', title=facet_titles[facet], name=facet, extras={'id':group_dict.id}, search_facets=search_facets) }}
+        {% snippet 'snippets/facet_list.html', title=facet_titles[facet], name=facet, extras={'id':group_dict.id}, search_facets=search_facets %}
       {% endfor %}

     close
diff --git a/ckan/templates/organization/bulk_process.html b/ckan/templates/organization/bulk_process.html
index 68f00f39a6c..23073108cc8 100644
--- a/ckan/templates/organization/bulk_process.html
+++ b/ckan/templates/organization/bulk_process.html
@@ -107,8 +107,8 @@

 {% endblock %}

 {% block secondary_content %}
-    {{  super() }}
-    {% for facet in c.facet_titles %}
-    {{ h.snippet('snippets/facet_list.html', title=c.facet_titles[facet], name=facet, extras={'id':group_dict.id}, search_facets=search_facets) }}
+  {{ super() }}
+  {% for facet in c.facet_titles %}
+    {% snippet 'snippets/facet_list.html', title=c.facet_titles[facet], name=facet, extras={'id':group_dict.id}, search_facets=search_facets %}
   {% endfor %}
 {% endblock %}
diff --git a/ckan/templates/organization/read.html b/ckan/templates/organization/read.html
index 466002aeaea..5ae33b3b77a 100644
--- a/ckan/templates/organization/read.html
+++ b/ckan/templates/organization/read.html
@@ -27,7 +27,7 @@
     {% endblock %}
   {% block packages_list %}
     {% if page.items %}
-      {{ h.snippet('snippets/package_list.html', packages=page.items) }}
+      {% snippet 'snippets/package_list.html', packages=page.items %}
     {% endif %}
   {% endblock %}
   {% block page_pagination %}
@@ -39,7 +39,7 @@

       {% for facet in facet_titles %}
-        {{ h.snippet('snippets/facet_list.html', title=facet_titles[facet], name=facet, extras={'id':group_dict.id}, search_facets=search_facets) }}
+        {% snippet 'snippets/facet_list.html', title=facet_titles[facet], name=facet, extras={'id':group_dict.id}, search_facets=search_facets %}
       {% endfor %}

     close
diff --git a/ckan/templates/package/search.html b/ckan/templates/package/search.html
index 7eb5452e204..971fca8f056 100644
--- a/ckan/templates/package/search.html
+++ b/ckan/templates/package/search.html
@@ -13,7 +13,7 @@
       {% block page_primary_action %}
         {% if h.check_access('package_create') %}

-            {{ h.snippet ('snippets/add_dataset.html', dataset_type=dataset_type) }}
+            {% snippet 'snippets/add_dataset.html', dataset_type=dataset_type %}

         {% endif %}
       {% endblock %}
@@ -35,7 +35,7 @@
         {% snippet 'snippets/search_form.html', form_id='dataset-search-form', type=dataset_type, query=q, sorting=sorting, sorting_selected=sort_by_selected, count=page.item_count, placeholder=h.humanize_entity_type('package', dataset_type, 'search placeholder') or _('Search datasets...'), facets=facets, show_empty=request.args, error=query_error, fields=fields %}
       {% endblock %}
       {% block package_search_results_list %}
-        {{ h.snippet('snippets/package_list.html', packages=page.items) }}
+        {% snippet 'snippets/package_list.html', packages=page.items %}
       {% endblock %}

@@ -64,7 +64,7 @@

       {% for facet in facet_titles %}
-        {{ h.snippet('snippets/facet_list.html', title=facet_titles[facet], name=facet, search_facets=search_facets) }}
+        {% snippet 'snippets/facet_list.html', title=facet_titles[facet], name=facet, search_facets=search_facets %}
       {% endfor %}

     close
diff --git a/ckan/templates/package/snippets/package_form.html b/ckan/templates/package/snippets/package_form.html
index 67c32d2fba9..4bf3539937a 100644
--- a/ckan/templates/package/snippets/package_form.html
+++ b/ckan/templates/package/snippets/package_form.html
@@ -6,7 +6,7 @@
 <form id="dataset-edit" method="post" action="{{ action }}" data-module="basic-form" novalidate>
   {{ h.csrf_input() }}
   {% block stages %}
-    {{ h.snippet('package/snippets/stages.html', stages=stage, dataset_type=dataset_type) }}
+    {% snippet 'package/snippets/stages.html', stages=stage, dataset_type=dataset_type %}
   {% endblock %}

   <input type="hidden" name="_ckan_phase" value="dataset_new_1" />
diff --git a/ckan/templates/package/snippets/resource_form.html b/ckan/templates/package/snippets/resource_form.html
index f90892a70fa..494459430d2 100644
--- a/ckan/templates/package/snippets/resource_form.html
+++ b/ckan/templates/package/snippets/resource_form.html
@@ -10,7 +10,7 @@
   {% block stages %}
     {# An empty stages variable will not show the stages #}
     {% if stage %}
-      {{ h.snippet('package/snippets/stages.html', stages=stage, pkg_name=pkg_name, dataset_type=dataset_type) }}
+      {% snippet 'package/snippets/stages.html', stages=stage, pkg_name=pkg_name, dataset_type=dataset_type %}
     {% endif %}
   {% endblock %}

snippet performance issue
**CKAN version**
all

**Describe the bug**
Heavy use of template `{% snippet %}`  slows page rendering times compared to alternatives like macros or `{% include %}`

**Steps to reproduce**
Compare the rendering time of a page using snippets with one that uses macros or `{% include %}` to display the same information. The activity feed, the search results page and customized dataset forms when using ckanext-scheming are examples of pages with lots of snippets.

**Expected behavior**
Render time for `{% snippet %}` should be similar to macros and `{% include %}`

**Additional details**
`{% snippet %}` is a great tag worth saving, not just for compatibility.

Unlike `{% include %}` snippets support passing parameters, and unlike macros snippet parameters can be added over time without causing errors. Snippets are easier for developers to use than deeply extended templates with blocks selectively overridden.

The performance issue may be caused by the way jinja2 converts templates into byte code - a template passed to `render()` needs to be interpreted and can't use the byte code cache, unlike extended templates or imported macros. Our `{% snippet %}` tag calls `render()` returning the resulting string so snippets are re-interpreted every time they are used, even if it's the exact same snippet used in a loop.

A possible fix is to have the snippet tag automatically convert snippets into a macro with the parameters passed by the caller. The macro-ified snippets could be generated with a dynamic template loader instead of creating them on the filesystem.  The snippet tag would convert itself into an `{% import %}` and a call to the macro generated.

@smotornyuk mentioned that current jinja2 now has an `{% include … without context %}` that we could have our snippet tag emit instead of calling render and inserting the contents. Should be an easy win.


diff --git a/prymer/offtarget/offtarget_detector.py b/prymer/offtarget/offtarget_detector.py
index d1c7868..71d3201 100644
--- a/prymer/offtarget/offtarget_detector.py
+++ b/prymer/offtarget/offtarget_detector.py
@@ -75,6 +75,7 @@
 """  # noqa: E501

 import itertools
+from collections import defaultdict
 from contextlib import AbstractContextManager
 from dataclasses import dataclass
 from dataclasses import field
@@ -83,6 +84,7 @@
 from types import TracebackType
 from typing import Optional
 from typing import Self
+from typing import TypeAlias
 from typing import TypeVar

 from ordered_set import OrderedSet
@@ -90,6 +92,7 @@
 from prymer.api.oligo import Oligo
 from prymer.api.primer_pair import PrimerPair
 from prymer.api.span import Span
+from prymer.api.span import Strand
 from prymer.offtarget.bwa import BWA_EXECUTABLE_NAME
 from prymer.offtarget.bwa import BwaAlnInteractive
 from prymer.offtarget.bwa import BwaHit
@@ -98,6 +101,9 @@

 PrimerType = TypeVar("PrimerType", bound=Oligo)

+ReferenceName: TypeAlias = str
+"""Alias for a reference sequence name."""
+

 @dataclass(init=True, frozen=True)
 class OffTargetResult:
@@ -334,27 +340,78 @@ def _build_off_target_result(
         result: OffTargetResult

         # Get the mappings for the left primer and right primer respectively
-        p1: BwaResult = hits_by_primer[primer_pair.left_primer.bases]
-        p2: BwaResult = hits_by_primer[primer_pair.right_primer.bases]
-
-        # Get all possible amplicons from the left_primer_mappings and right_primer_mappings
-        # primer hits, filtering if there are too many for either
-        if p1.hit_count > self._max_primer_hits or p2.hit_count > self._max_primer_hits:
+        left_bwa_result: BwaResult = hits_by_primer[primer_pair.left_primer.bases]
+        right_bwa_result: BwaResult = hits_by_primer[primer_pair.right_primer.bases]
+
+        # If there are too many hits, this primer pair will not pass. Exit early.
+        if (
+            left_bwa_result.hit_count > self._max_primer_hits
+            or right_bwa_result.hit_count > self._max_primer_hits
+        ):
             result = OffTargetResult(primer_pair=primer_pair, passes=False)
-        else:
-            amplicons = self._to_amplicons(p1.hits, p2.hits, self._max_amplicon_size)
-            result = OffTargetResult(
-                primer_pair=primer_pair,
-                passes=self._min_primer_pair_hits <= len(amplicons) <= self._max_primer_pair_hits,
-                spans=amplicons if self._keep_spans else [],
-                left_primer_spans=(
-                    [self._hit_to_span(h) for h in p1.hits] if self._keep_primer_spans else []
-                ),
-                right_primer_spans=(
-                    [self._hit_to_span(h) for h in p2.hits] if self._keep_primer_spans else []
-                ),
+            if self._cache_results:
+                self._primer_pair_cache[primer_pair] = replace(result, cached=True)
+            return result
+
+        # Map the hits by reference name
+        left_positive_hits: defaultdict[ReferenceName, list[BwaHit]] = defaultdict(list)
+        left_negative_hits: defaultdict[ReferenceName, list[BwaHit]] = defaultdict(list)
+        right_positive_hits: defaultdict[ReferenceName, list[BwaHit]] = defaultdict(list)
+        right_negative_hits: defaultdict[ReferenceName, list[BwaHit]] = defaultdict(list)
+
+        # Split the hits for left and right by reference name and strand
+        for hit in left_bwa_result.hits:
+            if hit.negative:
+                left_negative_hits[hit.refname].append(hit)
+            else:
+                left_positive_hits[hit.refname].append(hit)
+
+        for hit in right_bwa_result.hits:
+            if hit.negative:
+                right_negative_hits[hit.refname].append(hit)
+            else:
+                right_positive_hits[hit.refname].append(hit)
+
+        refnames: set[ReferenceName] = {
+            h.refname for h in itertools.chain(left_bwa_result.hits, right_bwa_result.hits)
+        }
+
+        # Build amplicons from hits on the same reference with valid relative orientation
+        amplicons: list[Span] = []
+        for refname in refnames:
+            amplicons.extend(
+                self._to_amplicons(
+                    positive_hits=left_positive_hits[refname],
+                    negative_hits=right_negative_hits[refname],
+                    max_len=self._max_amplicon_size,
+                    strand=Strand.POSITIVE,
+                )
+            )
+            amplicons.extend(
+                self._to_amplicons(
+                    positive_hits=right_positive_hits[refname],
+                    negative_hits=left_negative_hits[refname],
+                    max_len=self._max_amplicon_size,
+                    strand=Strand.NEGATIVE,
+                )
             )

+        result = OffTargetResult(
+            primer_pair=primer_pair,
+            passes=self._min_primer_pair_hits <= len(amplicons) <= self._max_primer_pair_hits,
+            spans=amplicons if self._keep_spans else [],
+            left_primer_spans=(
+                [self._hit_to_span(h) for h in left_bwa_result.hits]
+                if self._keep_primer_spans
+                else []
+            ),
+            right_primer_spans=(
+                [self._hit_to_span(h) for h in right_bwa_result.hits]
+                if self._keep_primer_spans
+                else []
+            ),
+        )
+
         if self._cache_results:
             self._primer_pair_cache[primer_pair] = replace(result, cached=True)

@@ -410,19 +467,89 @@ def mappings_of(self, primers: list[PrimerType]) -> dict[str, BwaResult]:
         return hits_by_primer

     @staticmethod
-    def _to_amplicons(lefts: list[BwaHit], rights: list[BwaHit], max_len: int) -> list[Span]:
-        """Takes a set of hits for one or more left primers and right primers and constructs
-        amplicon mappings anywhere a left primer hit and a right primer hit align in F/R
-        orientation up to `maxLen` apart on the same reference.  Primers may not overlap.
+    def _to_amplicons(
+        positive_hits: list[BwaHit], negative_hits: list[BwaHit], max_len: int, strand: Strand
+    ) -> list[Span]:
+        """Takes lists of positive strand hits and negative strand hits and constructs amplicon
+        mappings anywhere a positive strand hit and a negative strand hit occur where the end of
+        the negative strand hit is no more than `max_len` from the start of the positive strand
+        hit.
+
+        Primers may not overlap.
+
+        Args:
+            positive_hits: List of hits on the positive strand for one of the primers in the pair.
+            negative_hits: List of hits on the negative strand for the other primer in the pair.
+            max_len: Maximum length of amplicons to consider.
+            strand: The strand of the amplicon to generate. Set to Strand.POSITIVE if
+                `positive_hits` are for the left primer and `negative_hits` are for the right
+                primer. Set to Strand.NEGATIVE if `positive_hits` are for the right primer and
+                `negative_hits` are for the left primer.
+
+        Raises:
+            ValueError: If any of the positive hits are not on the positive strand, or any of the
+                negative hits are not on the negative strand. If hits are present on more than one
+                reference.
         """
+        if any(h.negative for h in positive_hits):
+            raise ValueError("Positive hits must be on the positive strand.")
+        if any(not h.negative for h in negative_hits):
+            raise ValueError("Negative hits must be on the negative strand.")
+
+        refnames: set[ReferenceName] = {
+            h.refname for h in itertools.chain(positive_hits, negative_hits)
+        }
+        if len(refnames) > 1:
+            raise ValueError(f"Hits are present on more than one reference: {refnames}")
+
+        # Exit early if one of the hit lists is empty - this will save unnecessary sorting of the
+        # other list
+        if len(positive_hits) == 0 or len(negative_hits) == 0:
+            return []
+
+        # Sort the positive strand hits by start position and the negative strand hits by *end*
+        # position. The `max_len` cutoff is based on negative_hit.end - positive_hit.start + 1.
+        positive_hits_sorted = sorted(positive_hits, key=lambda h: h.start)
+        negative_hits_sorted = sorted(negative_hits, key=lambda h: h.end)
+
         amplicons: list[Span] = []
-        for h1, h2 in itertools.product(lefts, rights):
-            if h1.negative == h2.negative or h1.refname != h2.refname:  # not F/R orientation
-                continue

-            plus, minus = (h2, h1) if h1.negative else (h1, h2)
-            if minus.start > plus.end and (minus.end - plus.start + 1) <= max_len:
-                amplicons.append(Span(refname=plus.refname, start=plus.start, end=minus.end))
+        # Track the position of the previously examined negative hit.
+        prev_negative_hit_index = 0
+        for positive_hit in positive_hits_sorted:
+            # Check only negative hits starting with the previously examined one.
+            for negative_hit_index, negative_hit in enumerate(
+                negative_hits_sorted[prev_negative_hit_index:],
+                start=prev_negative_hit_index,
+            ):
+                # TODO: Consider allowing overlapping positive and negative hits.
+                if (
+                    negative_hit.start > positive_hit.end
+                    and negative_hit.end - positive_hit.start + 1 <= max_len
+                ):
+                    # If the negative hit starts to the right of the positive hit, and the amplicon
+                    # length is <= max_len, add it to the list of amplicon hits to be returned.
+                    amplicons.append(
+                        Span(
+                            refname=positive_hit.refname,
+                            start=positive_hit.start,
+                            end=negative_hit.end,
+                            strand=strand,
+                        )
+                    )
+
+                if negative_hit.end - positive_hit.start + 1 > max_len:
+                    # Stop searching for negative hits to pair with this positive hit.
+                    # All subsequence negative hits will have amplicon length > max_len
+                    break
+
+                if negative_hit.end < positive_hit.start:
+                    # This positive hit is genomically right of the current negative hit.
+                    # All subsequent positive hits will also be genomically right of this negative
+                    # hit, so we should start at the one after this. If this index is past the end
+                    # of the list, the slice `negative_hits_sorted[prev_negative_hit_index:]` will
+                    # be empty.
+                    prev_negative_hit_index = negative_hit_index + 1

         return amplicons

diff --git a/tests/offtarget/test_offtarget.py b/tests/offtarget/test_offtarget.py
index f8f5f8c..a2f3a46 100644
--- a/tests/offtarget/test_offtarget.py
+++ b/tests/offtarget/test_offtarget.py
@@ -11,6 +11,7 @@
 from prymer.offtarget.bwa import BWA_EXECUTABLE_NAME
 from prymer.offtarget.bwa import BwaHit
 from prymer.offtarget.bwa import BwaResult
+from prymer.offtarget.bwa import Query
 from prymer.offtarget.offtarget_detector import OffTargetDetector
 from prymer.offtarget.offtarget_detector import OffTargetResult

@@ -171,68 +172,159 @@ def test_mappings_of(ref_fasta: Path, cache_results: bool) -> None:
             assert results_dict[p2.bases].hits[0] == expected_hit2

+# Test building an OffTargetResult for a primer pair with left/right hits on different references
+# and in different orientations
+def test_build_off_target_result(ref_fasta: Path) -> None:
+    hits_by_primer: dict[str, BwaResult] = {
+        "A" * 100: BwaResult(
+            query=Query(
+                id="left",
+                bases="A" * 100,
+            ),
+            hit_count=3,
+            hits=[
+                BwaHit.build("chr1", 100, False, "100M", 0),
+                BwaHit.build("chr1", 400, True, "100M", 0),
+                BwaHit.build("chr2", 100, False, "100M", 0),
+                BwaHit.build("chr3", 700, True, "100M", 0),
+            ],
+        ),
+        "C" * 100: BwaResult(
+            query=Query(
+                id="right",
+                bases="C" * 100,
+            ),
+            hit_count=2,
+            hits=[
+                BwaHit.build("chr1", 800, False, "100M", 0),
+                BwaHit.build("chr1", 200, True, "100M", 0),
+                BwaHit.build("chr3", 600, False, "100M", 0),
+            ],
+        ),
+    }
+
+    primer_pair = PrimerPair(
+        left_primer=Oligo(
+            tm=50,
+            penalty=0,
+            span=Span(refname="chr10", start=100, end=199, strand=Strand.POSITIVE),
+            bases="A" * 100,
+        ),
+        right_primer=Oligo(
+            tm=50,
+            penalty=0,
+            span=Span(refname="chr10", start=300, end=399, strand=Strand.NEGATIVE),
+            bases="C" * 100,
+        ),
+        amplicon_tm=100,
+        penalty=0,
+    )
+
+    with _build_detector(
+        ref_fasta=ref_fasta, max_primer_hits=10, max_primer_pair_hits=10
+    ) as detector:
+        off_target_result: OffTargetResult = detector._build_off_target_result(
+            primer_pair=primer_pair,
+            hits_by_primer=hits_by_primer,
+        )
+
+    assert set(off_target_result.spans) == {
+        Span(refname="chr1", start=100, end=299, strand=Strand.POSITIVE),
+        Span(refname="chr3", start=600, end=799, strand=Strand.NEGATIVE),
+    }
+
+
 # Test that using the cache (or not) does not affect the results
 @pytest.mark.parametrize("cache_results", [True, False])
 @pytest.mark.parametrize(
-    "test_id, left, right, expected",
+    "test_id, positive, negative, strand, expected",
     [
-        (
-            "No mappings - different refnames",
-            BwaHit.build("chr1", 100, False, "100M", 0),
-            BwaHit.build("chr2", 100, True, "100M", 0),
-            [],
-        ),
-        (
-            "No mappings - FF pair",
-            BwaHit.build("chr1", 100, True, "100M", 0),
-            BwaHit.build("chr1", 100, True, "100M", 0),
-            [],
-        ),
-        (
-            "No mappings - RR pair",
-            BwaHit.build("chr1", 100, False, "100M", 0),
-            BwaHit.build("chr1", 100, False, "100M", 0),
-            [],
-        ),
         (
             "No mappings - overlapping primers (1bp overlap)",
             BwaHit.build("chr1", 100, False, "100M", 0),
             BwaHit.build("chr1", 199, True, "100M", 0),
+            Strand.POSITIVE,
             [],
         ),
         (
             "No mappings - amplicon size too big (1bp too big)",
             BwaHit.build("chr1", 100, False, "100M", 0),
             BwaHit.build("chr1", 151, True, "100M", 0),
+            Strand.POSITIVE,
             [],
         ),
         (
             "Mappings - FR pair (R1 F)",
             BwaHit.build("chr1", 100, False, "100M", 0),
             BwaHit.build("chr1", 200, True, "100M", 0),
-            [Span(refname="chr1", start=100, end=299)],
+            Strand.POSITIVE,
+            [Span(refname="chr1", start=100, end=299, strand=Strand.POSITIVE)],
         ),
         (
             "Mappings - FR pair (R1 R)",
-            BwaHit.build("chr1", 200, True, "100M", 0),
             BwaHit.build("chr1", 100, False, "100M", 0),
-            [Span(refname="chr1", start=100, end=299)],
+            BwaHit.build("chr1", 200, True, "100M", 0),
+            Strand.NEGATIVE,
+            [Span(refname="chr1", start=100, end=299, strand=Strand.NEGATIVE)],
         ),
     ],
 )
 def test_to_amplicons(
     ref_fasta: Path,
     test_id: str,
-    left: BwaHit,
-    right: BwaHit,
+    positive: BwaHit,
+    negative: BwaHit,
+    strand: Strand,
     expected: list[Span],
     cache_results: bool,
 ) -> None:
     with _build_detector(ref_fasta=ref_fasta, cache_results=cache_results) as detector:
-        actual = detector._to_amplicons(lefts=[left], rights=[right], max_len=250)
+        actual = detector._to_amplicons(
+            positive_hits=[positive], negative_hits=[negative], max_len=250, strand=strand
+        )
         assert actual == expected, test_id

+@pytest.mark.parametrize("cache_results", [True, False])
+@pytest.mark.parametrize(
+    "positive, negative, expected_error",
+    [
+        (
+            # No mappings - different refnames
+            BwaHit.build("chr1", 100, False, "100M", 0),
+            BwaHit.build("chr2", 100, True, "100M", 0),
+            "Hits are present on more than one reference",
+        ),
+        (
+            # No mappings - FF pair
+            BwaHit.build("chr1", 100, True, "100M", 0),
+            BwaHit.build("chr1", 100, True, "100M", 0),
+            "Positive hits must be on the positive strand",
+        ),
+        (
+            # No mappings - RR pair
+            BwaHit.build("chr1", 100, False, "100M", 0),
+            BwaHit.build("chr1", 100, False, "100M", 0),
+            "Negative hits must be on the negative strand",
+        ),
+    ],
+)
+def test_to_amplicons_value_error(
+    ref_fasta: Path,
+    positive: BwaHit,
+    negative: BwaHit,
+    expected_error: str,
+    cache_results: bool,
+) -> None:
+    with (
+        _build_detector(ref_fasta=ref_fasta, cache_results=cache_results) as detector,
+        pytest.raises(ValueError, match=expected_error),
+    ):
+        detector._to_amplicons(
+            positive_hits=[positive], negative_hits=[negative], max_len=250, strand=Strand.POSITIVE
+        )
+
+
 def test_generic_filter(ref_fasta: Path) -> None:
     """
     This test isn't intended to validate any runtime assertions, but is a minimal example for the

Speed up from O(n^2) iteration over all left/right primer hits in `OffTargetDetector._to_amplicons`
`OffTargetDetector._to_amplicons` uses `itertools.product` over the left and right primer hits and evaluates each of them in a loop to identify valid left/right hit combinations for the pair.

Suggestion:

1. Get hits by primer sequence from `mappings_of`
2. Split into left and right hits
3. Group hits by refname
4. For each refname, split into left +, right -, left -, right+ hits
5. Build amplicons from left + and right - (amplicon strand +), left - and right + (amplicon strand -)

Building amplicons - dealing with only positive & negative in correct orientation and known to be on the same reference, with known amplicon strand
1. `itertools.product` over positive & negative primer hits
2. Check product size against min/max amplicon size range
3. If in range, return the product hit as positive start to negative end with the known reference name and amplicon strand

I think this sounds like a great idea.  I would make a couple of suggestions also:

1. You could _probably_ do the splitting into left/right/+/- and by refname all in one pass ... if you created a simple dataclass that held four lists (one for each strand and orientation).  You could then have a `dict[refname, new_class]` and place hits into the appropriate place.
2. If you sort all the sub-collections by start_pos you can short-circuit more, to avoid checking more combinations.  I.e. for a given "left" primer you can stop checking "right" primer hits once you get beyond the maximum amplicon size.