diff --git a/src/transformers/optimization.py b/src/transformers/optimization.py
index 0ca5d36d0f40..d00c65925ef2 100644
--- a/src/transformers/optimization.py
+++ b/src/transformers/optimization.py
@@ -393,45 +393,71 @@ def _get_wsd_scheduler_lambda(
     num_warmup_steps: int,
     num_stable_steps: int,
     num_decay_steps: int,
-    num_cycles: float,
+    warmup_type: str,
+    decay_type: str,
     min_lr_ratio: float,
+    num_cycles: float,
 ):
     if current_step < num_warmup_steps:
-        return float(current_step) / float(max(1, num_warmup_steps))
+        progress = float(current_step) / float(max(1, num_warmup_steps))
+        if warmup_type == "linear":
+            factor = progress
+        elif warmup_type == "cosine":
+            factor = 0.5 * (1.0 - math.cos(math.pi * progress))
+        elif warmup_type == "1-sqrt":
+            factor = 1.0 - math.sqrt(1.0 - progress)
+        factor = factor * (1.0 - min_lr_ratio) + min_lr_ratio
+        return max(0.0, factor)
+
     if current_step < num_warmup_steps + num_stable_steps:
         return 1.0
+
     if current_step < num_warmup_steps + num_stable_steps + num_decay_steps:
         progress = float(current_step - num_warmup_steps - num_stable_steps) / float(max(1, num_decay_steps))
-        value = max(0.0, 0.5 * (1.0 + math.cos(math.pi * float(num_cycles) * 2.0 * progress)))
-        return (1.0 - min_lr_ratio) * value + min_lr_ratio
+        if decay_type == "linear":
+            factor = 1.0 - progress
+        elif decay_type == "cosine":
+            factor = 0.5 * (1.0 + math.cos(math.pi * float(num_cycles) * 2.0 * progress))
+        elif decay_type == "1-sqrt":
+            factor = 1.0 - math.sqrt(progress)
+        factor = factor * (1.0 - min_lr_ratio) + min_lr_ratio
+        return max(0.0, factor)
     return min_lr_ratio

 def get_wsd_schedule(
     optimizer: Optimizer,
     num_warmup_steps: int,
-    num_stable_steps: int,
     num_decay_steps: int,
+    num_training_steps: Optional[int] = None,
+    num_stable_steps: Optional[int] = None,
+    warmup_type: str = "linear",
+    decay_type: str = "cosine",
     min_lr_ratio: float = 0,
     num_cycles: float = 0.5,
     last_epoch: int = -1,
 ):
     """
     Create a schedule with a learning rate that has three stages:
-    1. linear increase from 0 to initial lr.
-    2. constant lr (equal to initial lr).
-    3. decrease following the values of the cosine function between the initial lr set in the optimizer to
-       a fraction of initial lr.
+    1. warmup: increase from min_lr_ratio times the initial learning rate to the initial learning rate following a warmup_type.
+    2. stable: constant learning rate.
+    3. decay: decrease from the initial learning rate to min_lr_ratio times the initial learning rate following a decay_type.

     Args:
         optimizer ([`~torch.optim.Optimizer`]):
             The optimizer for which to schedule the learning rate.
         num_warmup_steps (`int`):
             The number of steps for the warmup phase.
-        num_stable_steps (`int`):
-            The number of steps for the stable phase.
         num_decay_steps (`int`):
-            The number of steps for the cosine annealing phase.
+            The number of steps for the decay phase.
+        num_training_steps (`int`, *optional*):
+            The total number of training steps. This is the sum of the warmup, stable and decay steps. If `num_stable_steps` is not provided, the stable phase will be `num_training_steps - num_warmup_steps - num_decay_steps`.
+        num_stable_steps (`int`, *optional*):
+            The number of steps for the stable phase. Please ensure that `num_warmup_steps + num_stable_steps + num_decay_steps` equals `num_training_steps`, otherwise the other steps will default to the minimum learning rate.
+        warmup_type (`str`, *optional*, defaults to "linear"):
+            The type of warmup to use. Can be 'linear', 'cosine' or '1-sqrt'.
+        decay_type (`str`, *optional*, defaults to "cosine"):
+            The type of decay to use. Can be 'linear', 'cosine' or '1-sqrt'.
         min_lr_ratio (`float`, *optional*, defaults to 0):
             The minimum learning rate as a ratio of the initial learning rate.
         num_cycles (`float`, *optional*, defaults to 0.5):
@@ -443,11 +469,29 @@ def get_wsd_schedule(
     Return:
         `torch.optim.lr_scheduler.LambdaLR` with the appropriate schedule.
     """
+
+    if num_training_steps is None and num_stable_steps is None:
+        raise ValueError("Either num_training_steps or num_stable_steps must be specified.")
+
+    if num_training_steps is not None and num_stable_steps is not None:
+        warnings.warn("Both num_training_steps and num_stable_steps are specified. num_stable_steps will be used.")
+
+    if warmup_type not in ["linear", "cosine", "1-sqrt"]:
+        raise ValueError(f"Unknown warmup type: {warmup_type}, expected 'linear', 'cosine' or '1-sqrt'")
+
+    if decay_type not in ["linear", "cosine", "1-sqrt"]:
+        raise ValueError(f"Unknown decay type: {decay_type}, expected 'linear', 'cosine' or '1-sqrt'")
+
+    if num_stable_steps is None:
+        num_stable_steps = num_training_steps - num_warmup_steps - num_decay_steps
+
     lr_lambda = partial(
         _get_wsd_scheduler_lambda,
         num_warmup_steps=num_warmup_steps,
         num_stable_steps=num_stable_steps,
         num_decay_steps=num_decay_steps,
+        warmup_type=warmup_type,
+        decay_type=decay_type,
         min_lr_ratio=min_lr_ratio,
         num_cycles=num_cycles,
     )
@@ -541,7 +585,12 @@ def scheduler_hook(param):
         return schedule_func(optimizer, num_warmup_steps=num_warmup_steps)

     if name == SchedulerType.WARMUP_STABLE_DECAY:
-        return schedule_func(optimizer, num_warmup_steps=num_warmup_steps, **scheduler_specific_kwargs)
+        return schedule_func(
+            optimizer,
+            num_warmup_steps=num_warmup_steps,
+            num_training_steps=num_training_steps,
+            **scheduler_specific_kwargs,
+        )

     # All other schedulers require `num_training_steps`
     if num_training_steps is None:

diff --git a/tests/optimization/test_optimization.py b/tests/optimization/test_optimization.py
index 6982583d2bec..4ab248e75a9a 100644
--- a/tests/optimization/test_optimization.py
+++ b/tests/optimization/test_optimization.py
@@ -153,8 +153,8 @@ def test_schedulers(self):
                 [0.0, 5.0, 10.0, 8.165, 7.071, 6.325, 5.774, 5.345, 5.0, 4.714],
             ),
             get_wsd_schedule: (
-                {"num_warmup_steps": 2, "num_stable_steps": 2, "num_decay_steps": 3, "min_lr_ratio": 0.1},
-                [0.0, 5.0, 10.0, 10.0, 10.0, 7.75, 3.25, 1.0, 1.0, 1.0],
+                {**common_kwargs, "num_decay_steps": 2, "min_lr_ratio": 0.0},
+                [0.0, 5.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 5.0],
             ),
         }

@@ -183,14 +183,34 @@ def test_get_scheduler(self):
                 "name": "warmup_stable_decay",
                 "optimizer": self.optimizer,
                 "num_warmup_steps": 2,
-                "scheduler_specific_kwargs": {"num_stable_steps": 1, "num_decay_steps": 3},
+                "num_training_steps": 10,
+                "scheduler_specific_kwargs": {
+                    "num_decay_steps": 2,
+                    "warmup_type": "linear",
+                    "decay_type": "linear",
+                },
+            },
+            {
+                "name": "warmup_stable_decay",
+                "optimizer": self.optimizer,
+                "num_warmup_steps": 2,
+                "num_training_steps": 10,
+                "scheduler_specific_kwargs": {
+                    "num_decay_steps": 2,
+                    "warmup_type": "cosine",
+                    "decay_type": "cosine",
+                },
             },
             {
                 "name": "warmup_stable_decay",
                 "optimizer": self.optimizer,
                 "num_warmup_steps": 2,
                 "num_training_steps": 10,
-                "scheduler_specific_kwargs": {"num_stable_steps": 1, "num_decay_steps": 3},
+                "scheduler_specific_kwargs": {
+                    "num_decay_steps": 2,
+                    "warmup_type": "1-sqrt",
+                    "decay_type": "1-sqrt",
+                },
             },
             {"name": "cosine", "optimizer": self.optimizer, "num_warmup_steps": 2, "num_training_steps": 10},
         ]

Support Constant Learning Rate with Cooldown
### Feature request

In `transformers.optimization` support `constant learning rate with cooldown` functions.

### Motivation

This method will implement that scaling experiments can be performed with significantly reduced compute and GPU hours by utilizing fewer but reusable training runs.
[SmolLM](https://huggingface.co/blog/smollm) had used this method to train a series of SOTA small language models.
Paper: [https://arxiv.org/pdf/2405.18392](https://arxiv.org/pdf/2405.18392)

### Your contribution

I've created a branch, I'm finishing the implementation of these functions, and intend to submit a PR.


diff --git a/README.md b/README.md
index d8c1c0c..d61a812 100644
--- a/README.md
+++ b/README.md
@@ -281,7 +281,6 @@ browser:
 login:
   username: ""
   password: ""
-
 ```

 ### <a name="ad-config"></a>2) Ad configuration
@@ -342,9 +341,11 @@ contact:

 republication_interval: # every X days the ad should be re-published

-id: # set automatically
-created_on: # set automatically
-updated_on: # set automatically
+# The following fields are automatically managed by the bot:
+id: # the ID assigned by kleinanzeigen.de
+created_on: # ISO timestamp when the ad was first published
+updated_on: # ISO timestamp when the ad was last published
+content_hash: # hash of the ad content, used to detect changes
 ```

 ### 3) Using an existing browser window
diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py
index d2e8889..ee325c0 100644
--- a/src/kleinanzeigen_bot/__init__.py
+++ b/src/kleinanzeigen_bot/__init__.py
@@ -19,7 +19,7 @@

 from . import utils, resources, extract
 from .i18n import Locale, get_current_locale, set_current_locale, get_translating_logger, pluralize
-from .utils import abspath, ainput, apply_defaults, ensure, is_frozen, safe_get, parse_datetime
+from .utils import abspath, ainput, apply_defaults, ensure, is_frozen, safe_get, parse_datetime, calculate_content_hash
 from .web_scraping_mixin import By, Element, Page, Is, WebScrapingMixin
 from ._version import __version__

@@ -262,6 +262,49 @@ def configure_file_logging(self) -> None:
         LOG.info("App version: %s", self.get_version())
         LOG.info("Python version: %s", sys.version)

+    def __check_ad_republication(self, ad_cfg: dict[str, Any], ad_cfg_orig: dict[str, Any], ad_file_relative: str) -> bool:
+        """
+        Check if an ad needs to be republished based on changes and republication interval.
+        Returns True if the ad should be republished.
+        """
+        if ad_cfg["updated_on"]:
+            last_updated_on = parse_datetime(ad_cfg["updated_on"])
+        elif ad_cfg["created_on"]:
+            last_updated_on = parse_datetime(ad_cfg["created_on"])
+        else:
+            return True
+
+        if not last_updated_on:
+            return True
+
+        # Check for changes first
+        if ad_cfg["id"]:
+            current_hash = calculate_content_hash(ad_cfg)
+            stored_hash = ad_cfg_orig.get("content_hash")
+
+            LOG.debug("Hash comparison for [%s]:", ad_file_relative)
+            LOG.debug("    Stored hash: %s", stored_hash)
+            LOG.debug("    Current hash: %s", current_hash)
+
+            if stored_hash and current_hash == stored_hash:
+                # No changes - check republication interval
+                ad_age = datetime.utcnow() - last_updated_on
+                if ad_age.days <= ad_cfg["republication_interval"]:
+                    LOG.info(
+                        " -> SKIPPED: ad [%s] was last published %d days ago. republication is only required every %s days",
+                        ad_file_relative,
+                        ad_age.days,
+                        ad_cfg["republication_interval"]
+                    )
+                    return False
+            else:
+                LOG.info("Changes detected in ad [%s], will republish", ad_file_relative)
+                # Update hash in original configuration
+                ad_cfg_orig["content_hash"] = current_hash
+                return True
+
+        return True
+
     def load_ads(self, *, ignore_inactive:bool = True, check_id:bool = True) -> list[tuple[str, dict[str, Any], dict[str, Any]]]:
         LOG.info("Searching for ad config files...")

@@ -275,8 +318,10 @@ def load_ads(self, *, ignore_inactive:bool = True, check_id:bool = True) -> list
         if not ad_files:
             return []

-        descr_prefix = self.config["ad_defaults"]["description"]["prefix"] or ""
-        descr_suffix = self.config["ad_defaults"]["description"]["suffix"] or ""
+        description_config = {
+            "prefix": self.config["ad_defaults"]["description"]["prefix"] or "",
+            "suffix": self.config["ad_defaults"]["description"]["suffix"] or ""
+        }

         ids = []
         use_specific_ads = False
@@ -308,24 +353,10 @@ def load_ads(self, *, ignore_inactive:bool = True, check_id:bool = True) -> list
                     continue

                 if self.ads_selector == "due":
-                    if ad_cfg["updated_on"]:
-                        last_updated_on = parse_datetime(ad_cfg["updated_on"])
-                    elif ad_cfg["created_on"]:
-                        last_updated_on = parse_datetime(ad_cfg["created_on"])
-                    else:
-                        last_updated_on = None
-
-                    if last_updated_on:
-                        ad_age = datetime.utcnow() - last_updated_on
-                        if ad_age.days <= ad_cfg["republication_interval"]:
-                            LOG.info(" -> SKIPPED: ad [%s] was last published %d days ago. republication is only required every %s days",
-                                ad_file_relative,
-                                ad_age.days,
-                                ad_cfg["republication_interval"]
-                            )
-                            continue
-
-            ad_cfg["description"] = descr_prefix + (ad_cfg["description"] or "") + descr_suffix
+                    if not self.__check_ad_republication(ad_cfg, ad_cfg_orig, ad_file_relative):
+                        continue
+
+            ad_cfg["description"] = description_config["prefix"] + (ad_cfg["description"] or "") + description_config["suffix"]
             ad_cfg["description"] = ad_cfg["description"].replace("@", "(at)")
             ensure(len(ad_cfg["description"]) <= 4000, f"Length of ad description including prefix and suffix exceeds 4000 chars. @ [{ad_file}]")

@@ -749,15 +780,17 @@ async def publish_ad(self, ad_file:str, ad_cfg: dict[str, Any], ad_cfg_orig: dic

         await self.web_await(lambda: "p-anzeige-aufgeben-bestaetigung.html?adId=" in self.page.url, timeout = 20)

-        ad_cfg_orig["updated_on"] = datetime.utcnow().isoformat()
-        if not ad_cfg["created_on"] and not ad_cfg["id"]:
-            ad_cfg_orig["created_on"] = ad_cfg_orig["updated_on"]
-
         # extract the ad id from the URL's query parameter
         current_url_query_params = urllib_parse.parse_qs(urllib_parse.urlparse(self.page.url).query)
         ad_id = int(current_url_query_params.get("adId", [])[0])
         ad_cfg_orig["id"] = ad_id

+        # Update content hash after successful publication
+        ad_cfg_orig["content_hash"] = calculate_content_hash(ad_cfg)
+        ad_cfg_orig["updated_on"] = datetime.utcnow().isoformat()
+        if not ad_cfg["created_on"] and not ad_cfg["id"]:
+            ad_cfg_orig["created_on"] = ad_cfg_orig["updated_on"]
+
         LOG.info(" -> SUCCESS: ad published with ID %s", ad_id)

         utils.save_dict(ad_file, ad_cfg_orig)
diff --git a/src/kleinanzeigen_bot/extract.py b/src/kleinanzeigen_bot/extract.py
index d08f31e..7d87df9 100644
--- a/src/kleinanzeigen_bot/extract.py
+++ b/src/kleinanzeigen_bot/extract.py
@@ -11,7 +11,7 @@
 import json

 from .i18n import get_translating_logger, pluralize
-from .utils import is_integer, parse_decimal, save_dict
+from .utils import is_integer, parse_decimal, save_dict, calculate_content_hash
 from .web_scraping_mixin import Browser, By, Element, Is, WebScrapingMixin

 __all__ = [
@@ -269,6 +269,9 @@ async def _extract_ad_page_info(self, directory:str, ad_id:int) -> dict[str, Any
         info['created_on'] = creation_date
         info['updated_on'] = None  # will be set later on

+        # Calculate the initial hash for the downloaded ad
+        info['content_hash'] = calculate_content_hash(info)
+
         return info

     async def _extract_category_from_ad_page(self) -> str:
diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml
index 1b7426a..3f38417 100644
--- a/src/kleinanzeigen_bot/resources/translations.de.yaml
+++ b/src/kleinanzeigen_bot/resources/translations.de.yaml
@@ -113,6 +113,12 @@ kleinanzeigen_bot/__init__.py:
     'Downloaded ad with id %d': 'Anzeige mit der ID %d heruntergeladen'
     'The page with the id %d does not exist!': 'Die Seite mit der ID %d existiert nicht!'

+  __check_ad_republication:
+    "Hash comparison for [%s]:": "Hash-Vergleich für [%s]:"
+    "    Stored hash: %s": "    Gespeicherter Hash: %s"
+    "    Current hash: %s": "    Aktueller Hash: %s"
+    "Changes detected in ad [%s], will republish": "Änderungen in Anzeige [%s] erkannt, wird neu veröffentlicht"
+

 #################################################
 kleinanzeigen_bot/extract.py:
diff --git a/src/kleinanzeigen_bot/utils.py b/src/kleinanzeigen_bot/utils.py
index e525471..e177ef6 100644
--- a/src/kleinanzeigen_bot/utils.py
+++ b/src/kleinanzeigen_bot/utils.py
@@ -3,7 +3,7 @@
 SPDX-License-Identifier: AGPL-3.0-or-later
 SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
 """
-import asyncio, copy, decimal, inspect, json, logging, os, re, socket, sys, traceback, time
+import asyncio, copy, decimal, inspect, json, logging, os, re, socket, sys, traceback, time, hashlib
 from importlib.resources import read_text as get_resource_as_string
 from collections.abc import Callable
 from datetime import datetime
@@ -333,3 +333,34 @@ def parse_datetime(date:datetime | str | None) -> datetime | None:
     if isinstance(date, datetime):
         return date
     return datetime.fromisoformat(date)
+
+
+def calculate_content_hash(ad_cfg: dict[str, Any]) -> str:
+    """Calculate a hash for user-modifiable fields of the ad."""
+
+    # Relevant fields for the hash
+    content = {
+        "active": bool(ad_cfg.get("active", True)),  # Explicitly convert to bool
+        "type": str(ad_cfg.get("type", "")),  # Explicitly convert to string
+        "title": str(ad_cfg.get("title", "")),
+        "description": str(ad_cfg.get("description", "")),
+        "category": str(ad_cfg.get("category", "")),
+        "price": str(ad_cfg.get("price", "")),  # Price always as string
+        "price_type": str(ad_cfg.get("price_type", "")),
+        "special_attributes": dict(ad_cfg.get("special_attributes", {})),  # Copy the dict
+        "shipping_type": str(ad_cfg.get("shipping_type", "")),
+        "shipping_costs": str(ad_cfg.get("shipping_costs", "")),
+        "shipping_options": sorted([str(x) for x in (ad_cfg.get("shipping_options") or [])]),  # Convert to list and sort
+        "sell_directly": bool(ad_cfg.get("sell_directly", False)),  # Explicitly convert to bool
+        "images": sorted([os.path.basename(img) if isinstance(img, str) else str(img) for img in ad_cfg.get("images", [])]),  # Only filenames
+        "contact": {
+            "name": str(ad_cfg.get("contact", {}).get("name", "")),
+            "street": str(ad_cfg.get("contact", {}).get("street", "None")),  # Explicitly "None" as string for None values
+            "zipcode": str(ad_cfg.get("contact", {}).get("zipcode", "")),
+            "phone": str(ad_cfg.get("contact", {}).get("phone", ""))
+        }
+    }
+
+    # Create sorted JSON string for consistent hashes
+    content_str = json.dumps(content, sort_keys=True)
+    return hashlib.sha256(content_str.encode()).hexdigest()

[ENH] Change detection
### 😕 Addressed Problem

If i change something like the text and try to republish it does not work if the publish interval is not met

### 💡 Suggested Solution

Save a hash from the config file to have change detection
#### Problems:
- Does not detect changes in images if they have the same name as before

### 🔎 Alternatives

_No response_

### 🗺 Additional Context

_No response_

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct

With which options do you start the bot? --ads=all or --ads=<id> will post a new ad and delete the old one. Only due will do nothing as excepted.
I did not add the --ads parameter, i just run publish
So one possibility could be to set the ads parameter according your needs. "all" to republish all ads or the specific id for the ad you have changed.


diff --git a/code/perception/src/lidar_distance.py b/code/perception/src/lidar_distance.py
old mode 100644
new mode 100755
index 939224c5..5c937be0
--- a/code/perception/src/lidar_distance.py
+++ b/code/perception/src/lidar_distance.py
@@ -3,7 +3,7 @@
 import rospy
 import ros_numpy
 import numpy as np
-import lidar_filter_utility
+from lidar_filter_utility import bounding_box, remove_field_name
 from sensor_msgs.msg import PointCloud2, Image as ImageMsg
 from sklearn.cluster import DBSCAN
 from cv_bridge import CvBridge
@@ -189,14 +189,12 @@ def calculate_image(self, coordinates, focus):
             return None

         # Apply bounding box filter
-        reconstruct_bit_mask = lidar_filter_utility.bounding_box(coordinates, **params)
+        reconstruct_bit_mask = bounding_box(coordinates, **params)
         reconstruct_coordinates = coordinates[reconstruct_bit_mask]

         # Remove the "intensity" field and convert to a NumPy array
         reconstruct_coordinates_xyz = np.array(
-            lidar_filter_utility.remove_field_name(
-                reconstruct_coordinates, "intensity"
-            ).tolist()
+            remove_field_name(reconstruct_coordinates, "intensity").tolist()
         )

         # Reconstruct the image based on the focus
@@ -256,51 +254,64 @@ def reconstruct_img_from_lidar(self, coordinates_xyz, focus):
         img = np.zeros(shape=(720, 1280), dtype=np.float32)
         dist_array = np.zeros(shape=(720, 1280, 3), dtype=np.float32)

-        # Process each point in the point cloud
-        for c in coordinates_xyz:
-            if focus == "Center":  # Compute image for the center view
-                point = np.array([c[1], c[2], c[0], 1])
-                pixel = np.matmul(m, point)  # Project 3D point to 2D image coordinates
-                x, y = int(pixel[0] / pixel[2]), int(
-                    pixel[1] / pixel[2]
-                )  # Normalize coordinates
-                if (
-                    0 <= x <= 1280 and 0 <= y <= 720
-                ):  # Check if coordinates are within image bounds
-                    img[719 - y][1279 - x] = c[0]  # Set depth value
-                    dist_array[719 - y][1279 - x] = np.array(
-                        [c[0], c[1], c[2]], dtype=np.float32
-                    )
-
-            if focus == "Back":  # Compute image for the rear view
-                point = np.array([c[1], c[2], c[0], 1])
-                pixel = np.matmul(m, point)
-                x, y = int(pixel[0] / pixel[2]), int(pixel[1] / pixel[2])
-                if 0 <= x <= 1280 and 0 <= y < 720:
-                    img[y][1279 - x] = -c[0]
-                    dist_array[y][1279 - x] = np.array(
-                        [-c[0], c[1], c[2]], dtype=np.float32
-                    )
-
-            if focus == "Left":  # Compute image for the left view
-                point = np.array([c[0], c[2], c[1], 1])
-                pixel = np.matmul(m, point)
-                x, y = int(pixel[0] / pixel[2]), int(pixel[1] / pixel[2])
-                if 0 <= x <= 1280 and 0 <= y <= 720:
-                    img[719 - y][1279 - x] = c[1]
-                    dist_array[y][1279 - x] = np.array(
-                        [c[0], c[1], c[2]], dtype=np.float32
-                    )
-
-            if focus == "Right":  # Compute image for the right view
-                point = np.array([c[0], c[2], c[1], 1])
-                pixel = np.matmul(m, point)
-                x, y = int(pixel[0] / pixel[2]), int(pixel[1] / pixel[2])
-                if 0 <= x < 1280 and 0 <= y < 720:
-                    img[y][1279 - x] = -c[1]
-                    dist_array[y][1279 - x] = np.array(
-                        [c[0], c[1], c[2]], dtype=np.float32
-                    )
+        # Prepare points based on focus
+        if focus in ["Center", "Back"]:
+            points = np.column_stack(
+                (
+                    coordinates_xyz[:, 1],
+                    coordinates_xyz[:, 2],
+                    coordinates_xyz[:, 0],
+                    np.ones(coordinates_xyz.shape[0]),
+                )
+            )
+        elif focus in ["Left", "Right"]:
+            points = np.column_stack(
+                (
+                    coordinates_xyz[:, 0],
+                    coordinates_xyz[:, 2],
+                    coordinates_xyz[:, 1],
+                    np.ones(coordinates_xyz.shape[0]),
+                )
+            )
+        else:
+            rospy.logwarn(f"Unknown focus: {focus}. Skipping image calculation.")
+            return None
+
+        # Project 3D points to 2D image coordinates
+        pixels = np.dot(m, points.T).T
+        x = (pixels[:, 0] / pixels[:, 2]).astype(int)
+        y = (pixels[:, 1] / pixels[:, 2]).astype(int)
+
+        # Filter valid coordinates
+        valid_indices = (x >= 0) & (x < 1280) & (y >= 0) & (y < 720)
+        x = x[valid_indices]
+        y = y[valid_indices]
+        valid_coordinates = coordinates_xyz[valid_indices]
+
+        if focus == "Center":
+            img[719 - y, 1279 - x] = valid_coordinates[:, 0]
+            dist_array[719 - y, 1279 - x] = valid_coordinates
+        elif focus == "Back":
+            img[y, 1279 - x] = -valid_coordinates[:, 0]
+            dist_array[y, 1279 - x] = np.column_stack(
+                (
+                    -valid_coordinates[:, 0],
+                    valid_coordinates[:, 1],
+                    valid_coordinates[:, 2],
+                )
+            )
+        elif focus == "Left":
+            img[719 - y, 1279 - x] = valid_coordinates[:, 1]
+            dist_array[719 - y, 1279 - x] = valid_coordinates
+        elif focus == "Right":
+            img[y, 1279 - x] = -valid_coordinates[:, 1]
+            dist_array[y, 1279 - x] = np.column_stack(
+                (
+                    valid_coordinates[:, 0],
+                    -valid_coordinates[:, 1],
+                    valid_coordinates[:, 2],
+                )
+            )

         return dist_array

Seperate tasks from lidar_distance in seperate nodes for better performance
### Feature Description

The tasks of image calculation and clustering data points should be seperated into seperate nodes to improve the node performance which should reduce the latency in the visualization.

### Definition of Done

- Create lidar_cluster node
- lidar_cluster works fine
- lidar_distance works fine

### Testability

_No response_

### Dependencies

This is an addition to Issue #524.


diff --git a/docs/reference/meson-compatibility.rst b/docs/reference/meson-compatibility.rst
index 743dde4a2..6b339d60e 100644
--- a/docs/reference/meson-compatibility.rst
+++ b/docs/reference/meson-compatibility.rst
@@ -45,6 +45,14 @@ versions.
    Meson 1.3.0 or later is required for compiling extension modules
    targeting the Python limited API.

+.. option:: 1.6.0
+
+   Meson 1.6.0 or later is required to support ``license`` and
+   ``license-files`` dynamic fields in ``pyproject.toml`` and to
+   populate the package license and license files from the ones
+   declared via the ``project()`` call in ``meson.build``. This also
+   requires ``pyproject-metadata`` version 0.9.0 or later.
+
 Build front-ends by default build packages in an isolated Python
 environment where build dependencies are installed. Most often, unless
 a package or its build dependencies declare explicitly a version
diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py
index 6f982c71d..103a87408 100644
--- a/mesonpy/__init__.py
+++ b/mesonpy/__init__.py
@@ -53,6 +53,20 @@
 from mesonpy._compat import cached_property, read_binary

+try:
+    from packaging.licenses import InvalidLicenseExpression, canonicalize_license_expression
+except ImportError:
+    # PEP-639 support requires packaging >= 24.2.
+    def canonicalize_license_expression(s: str) -> str:  # type: ignore[misc]
+        warnings.warn(
+            'canonicalization and validation of license expression in "project.license" '
+            'as defined by PEP-639 requires packaging version 24.2 or later.', stacklevel=2)
+        return s
+
+    class InvalidLicenseExpression(Exception):  # type: ignore[no-redef]
+        pass
+
+
 if typing.TYPE_CHECKING:  # pragma: no cover
     from typing import Any, Callable, DefaultDict, Dict, List, Literal, Optional, Sequence, TextIO, Tuple, Type, TypeVar, Union

@@ -65,6 +79,9 @@
     MesonArgs = Mapping[MesonArgsKeys, List[str]]

+_PYPROJECT_METADATA_VERSION = tuple(map(int, pyproject_metadata.__version__.split('.')[:2]))
+_SUPPORTED_DYNAMIC_FIELDS = {'version', } if _PYPROJECT_METADATA_VERSION < (0, 9) else {'version', 'license', 'license-files'}
+
 _NINJA_REQUIRED_VERSION = '1.8.2'
 _MESON_REQUIRED_VERSION = '0.63.3' # keep in sync with the version requirement in pyproject.toml

@@ -246,11 +263,15 @@ def from_pyproject(  # type: ignore[override]
         metadata = super().from_pyproject(data, project_dir, metadata_version)

         # Check for unsupported dynamic fields.
-        unsupported_dynamic = set(metadata.dynamic) - {'version', }
+        unsupported_dynamic = set(metadata.dynamic) - _SUPPORTED_DYNAMIC_FIELDS  # type: ignore[operator]
         if unsupported_dynamic:
             fields = ', '.join(f'"{x}"' for x in unsupported_dynamic)
             raise pyproject_metadata.ConfigurationError(f'Unsupported dynamic fields: {fields}')

+        # Validate license field to be a valid SDPX license expression.
+        if isinstance(metadata.license, str):
+            metadata.license = canonicalize_license_expression(metadata.license)
+
         return metadata

     @property
@@ -339,13 +360,6 @@ def _data_dir(self) -> str:
     def _libs_dir(self) -> str:
         return f'.{self._metadata.distribution_name}.mesonpy.libs'

-    @property
-    def _license_file(self) -> Optional[pathlib.Path]:
-        license_ = self._metadata.license
-        if license_ and isinstance(license_, pyproject_metadata.License):
-            return license_.file
-        return None
-
     @property
     def wheel(self) -> bytes:
         """Return WHEEL file for dist-info."""
@@ -428,9 +442,17 @@ def _wheel_write_metadata(self, whl: mesonpy._wheelfile.WheelFile) -> None:
         if self.entrypoints_txt:
             whl.writestr(f'{self._distinfo_dir}/entry_points.txt', self.entrypoints_txt)

-        # add license (see https://github.com/mesonbuild/meson-python/issues/88)
-        if self._license_file:
-            whl.write(self._license_file, f'{self._distinfo_dir}/{os.path.basename(self._license_file)}')
+        # Add pre-PEP-639 license files.
+        if isinstance(self._metadata.license, pyproject_metadata.License):
+            license_file = self._metadata.license.file
+            if license_file:
+                whl.write(license_file, f'{self._distinfo_dir}/{os.path.basename(license_file)}')
+
+        # Add PEP-639 license-files. Use ``getattr()`` for compatibility with pyproject-metadata < 0.9.0.
+        license_files = getattr(self._metadata, 'license_files', None)
+        if license_files:
+            for f in license_files:
+                whl.write(f, f'{self._distinfo_dir}/licenses/{pathlib.Path(f).as_posix()}')

     def build(self, directory: Path) -> pathlib.Path:
         wheel_file = pathlib.Path(directory, f'{self.name}.whl')
@@ -708,17 +730,34 @@ def __init__(
             # set version from meson.build if version is declared as dynamic
             if 'version' in self._metadata.dynamic:
                 version = self._meson_version
-                if version == 'undefined':
+                if version is None:
                     raise pyproject_metadata.ConfigurationError(
                         'Field "version" declared as dynamic but version is not defined in meson.build')
                 self._metadata.version = packaging.version.Version(version)
+            if 'license' in self._metadata.dynamic:
+                license = self._meson_license
+                if license is None:
+                    raise pyproject_metadata.ConfigurationError(
+                        'Field "license" declared as dynamic but license is not specified in meson.build')
+                # mypy is not happy when analyzing typing based on
+                # pyproject-metadata < 0.9 where license needs to be of
+                # License type.  However, this code is not executed if
+                # pyproject-metadata is older than 0.9 because then dynamic
+                # license is not allowed.
+                self._metadata.license = license  # type: ignore[assignment, unused-ignore]
+            if 'license-files' in self._metadata.dynamic:
+                self._metadata.license_files = self._meson_license_files
         else:
             # if project section is missing, use minimal metdata from meson.build
             name, version = self._meson_name, self._meson_version
-            if version == 'undefined':
+            if not version:
                 raise pyproject_metadata.ConfigurationError(
                     'Section "project" missing in pyproject.toml and version is not defined in meson.build')
-            self._metadata = Metadata(name=name, version=packaging.version.Version(version))
+            kwargs = {
+                'license': self._meson_license,
+                'license_files': self._meson_license_files
+            } if _PYPROJECT_METADATA_VERSION >= (0, 9) else {}
+            self._metadata = Metadata(name=name, version=packaging.version.Version(version), **kwargs)

         # verify that we are running on a supported interpreter
         if self._metadata.requires_python:
@@ -829,17 +868,44 @@ def _manifest(self) -> DefaultDict[str, List[Tuple[pathlib.Path, str]]]:

     @property
     def _meson_name(self) -> str:
-        """Name in meson.build."""
-        name = self._info('intro-projectinfo')['descriptive_name']
-        assert isinstance(name, str)
-        return name
+        """The project name specified with ``project()`` in meson.build."""
+        value = self._info('intro-projectinfo')['descriptive_name']
+        assert isinstance(value, str)
+        return value
+
+    @property
+    def _meson_version(self) -> Optional[str]:
+        """The version specified with the ``version`` argument to ``project()`` in meson.build."""
+        value = self._info('intro-projectinfo')['version']
+        assert isinstance(value, str)
+        if value == 'undefined':
+            return None
+        return value
+
+    @property
+    def _meson_license(self) -> Optional[str]:
+        """The license specified with the ``license`` argument to ``project()`` in meson.build."""
+        value = self._info('intro-projectinfo').get('license', None)
+        if value is None:
+            return None
+        assert isinstance(value, list)
+        if len(value) > 1:
+            raise pyproject_metadata.ConfigurationError(
+                'Using a list of strings for the license declared in meson.build is ambiguous: use a SPDX license expression')
+        value = value[0]
+        assert isinstance(value, str)
+        if value == 'unknown':
+            return None
+        return str(canonicalize_license_expression(value)) # str() is to make mypy happy

     @property
-    def _meson_version(self) -> str:
-        """Version in meson.build."""
-        name = self._info('intro-projectinfo')['version']
-        assert isinstance(name, str)
-        return name
+    def _meson_license_files(self) -> Optional[List[pathlib.Path]]:
+        """The license files specified with the ``license_files`` argument to ``project()`` in meson.build."""
+        value = self._info('intro-projectinfo').get('license_files', None)
+        if not value:
+            return None
+        assert isinstance(value, list)
+        return [pathlib.Path(x) for x in value]

     def sdist(self, directory: Path) -> pathlib.Path:
         """Generates a sdist (source distribution) in the specified directory."""
@@ -847,7 +913,8 @@ def sdist(self, directory: Path) -> pathlib.Path:
         self._run(self._meson + ['dist', '--allow-dirty', '--no-tests', '--formats', 'gztar', *self._meson_args['dist']])

         dist_name = f'{self._metadata.distribution_name}-{self._metadata.version}'
-        meson_dist_name = f'{self._meson_name}-{self._meson_version}'
+        meson_version = self._meson_version or 'undefined'
+        meson_dist_name = f'{self._meson_name}-{meson_version}'
         meson_dist_path = pathlib.Path(self._build_dir, 'meson-dist', f'{meson_dist_name}.tar.gz')
         sdist_path = pathlib.Path(directory, f'{dist_name}.tar.gz')
         pyproject_toml_mtime = 0
@@ -1023,7 +1090,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
         warnings.showwarning = _showwarning
         try:
             return func(*args, **kwargs)
-        except (Error, pyproject_metadata.ConfigurationError) as exc:
+        except (Error, InvalidLicenseExpression, pyproject_metadata.ConfigurationError) as exc:
             prefix = f'{style.ERROR}meson-python: error:{style.RESET} '
             _log('\n' + textwrap.indent(str(exc), prefix))
             raise SystemExit(1) from exc
diff --git a/pyproject.toml b/pyproject.toml
index 2247a46df..a64a26d1e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,7 +9,7 @@ requires = [
   'meson >= 0.64.0; python_version < "3.12"',
   'meson >= 1.2.3; python_version >= "3.12"',
   'packaging >= 19.0',
-  'pyproject-metadata >= 0.8.0',
+  'pyproject-metadata >= 0.9.0',
   'tomli >= 1.0.0; python_version < "3.11"',
 ]

@@ -37,7 +37,7 @@ dependencies = [
   'meson >= 0.64.0; python_version < "3.12"',
   'meson >= 1.2.3; python_version >= "3.12"',
   'packaging >= 19.0',
-  'pyproject-metadata >= 0.8.0',
+  'pyproject-metadata >= 0.9.0',
   'tomli >= 1.0.0; python_version < "3.11"',
 ]

diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 0bfca7e2d..cc48c21c0 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -98,10 +98,6 @@ jobs:
           - os: windows-latest
             python: '3.12'
             meson: '@git+https://github.com/mesonbuild/meson.git'
-          # Test with oldest supported pyproject-metadata
-          - os: ubuntu-latest
-            python: '3.12'
-            pyproject_metadata: '==0.8.0'

     steps:
       - name: Checkout
diff --git a/tests/conftest.py b/tests/conftest.py
index 1280a5d32..b53161d61 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -26,8 +26,23 @@
 from mesonpy._util import chdir

+_meson_ver_str = subprocess.run(['meson', '--version'], check=True, stdout=subprocess.PIPE, text=True).stdout
+MESON_VERSION = tuple(map(int, _meson_ver_str.split('.')[:3]))
+
+
 def metadata(data):
     meta, other = packaging.metadata.parse_email(data)
+    # PEP-639 support requires packaging >= 24.1.  Add minimal
+    # handling of PEP-639 fields here to allow testing with older
+    # packaging releases.
+    value = other.pop('license-expression', None)
+    if value is not None:
+        # The ``License-Expression`` header should appear only once.
+        assert len(value) == 1
+        meta['license-expression'] = value[0]
+    value = other.pop('license-file', None)
+    if value is not None:
+        meta['license-file'] = value
     assert not other
     return meta

diff --git a/tests/packages/license-pep639/LICENSES/BSD-3-Clause.txt b/tests/packages/license-pep639/LICENSES/BSD-3-Clause.txt
new file mode 100644
index 000000000..ce9b60c27
--- /dev/null
+++ b/tests/packages/license-pep639/LICENSES/BSD-3-Clause.txt
@@ -0,0 +1,1 @@
+Placeholder, just for testing.
diff --git a/tests/packages/license-pep639/LICENSES/MIT.txt b/tests/packages/license-pep639/LICENSES/MIT.txt
new file mode 100644
index 000000000..ce9b60c27
--- /dev/null
+++ b/tests/packages/license-pep639/LICENSES/MIT.txt
@@ -0,0 +1,1 @@
+Placeholder, just for testing.
diff --git a/tests/packages/license-pep639/meson.build b/tests/packages/license-pep639/meson.build
new file mode 100644
index 000000000..495a55636
--- /dev/null
+++ b/tests/packages/license-pep639/meson.build
@@ -0,0 +1,5 @@
+# SPDX-FileCopyrightText: 2022 The meson-python developers
+#
+# SPDX-License-Identifier: MIT
+
+project('license-pep639', version: '1.0.0')
diff --git a/tests/packages/license-pep639/pyproject.toml b/tests/packages/license-pep639/pyproject.toml
new file mode 100644
index 000000000..97416f3a8
--- /dev/null
+++ b/tests/packages/license-pep639/pyproject.toml
@@ -0,0 +1,13 @@
+# SPDX-FileCopyrightText: 2022 The meson-python developers
+#
+# SPDX-License-Identifier: MIT
+
+[build-system]
+build-backend = 'mesonpy'
+requires = ['meson-python']
+
+[project]
+name = 'license-pep639'
+version = '1.0.0'
+license = 'MIT OR BSD-3-Clause'
+license-files = ['LICENSES/*']
diff --git a/tests/packages/missing-version/meson.build b/tests/packages/missing-version/meson.build
deleted file mode 100644
index 4ccf75bce..000000000
--- a/tests/packages/missing-version/meson.build
+++ /dev/null
@@ -1,5 +0,0 @@
-# SPDX-FileCopyrightText: 2023 The meson-python developers
-#
-# SPDX-License-Identifier: MIT
-
-project('missing-version', version: '1.0.0')
diff --git a/tests/packages/missing-version/pyproject.toml b/tests/packages/missing-version/pyproject.toml
deleted file mode 100644
index 6538dd09e..000000000
--- a/tests/packages/missing-version/pyproject.toml
+++ /dev/null
@@ -1,10 +0,0 @@
-# SPDX-FileCopyrightText: 2023 The meson-python developers
-#
-# SPDX-License-Identifier: MIT
-
-[build-system]
-build-backend = 'mesonpy'
-requires = ['meson-python']
-
-[project]
-name = 'missing-version'
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
index d219b90ce..6609cd8fd 100644
--- a/tests/test_metadata.py
+++ b/tests/test_metadata.py
@@ -12,6 +12,12 @@
 from mesonpy import Metadata

+try:
+    import packaging.licenses as packaging_licenses
+except ImportError:
+    packaging_licenses = None
+
+
 def test_package_name():
     name = 'package.Test'
     metadata = Metadata(name='package.Test', version=packaging.version.Version('0.0.1'))
@@ -45,7 +51,7 @@ def test_unsupported_dynamic():
         Metadata.from_pyproject(pyproject, pathlib.Path())

-def test_missing_version(package_missing_version):
+def test_missing_version():
     pyproject = {'project': {
         'name': 'missing-version',
     }}
@@ -57,3 +63,25 @@ def test_missing_version(package_missing_version):
     ))
     with pytest.raises(pyproject_metadata.ConfigurationError, match=match):
         Metadata.from_pyproject(pyproject, pathlib.Path())
+
+
+@pytest.mark.skipif(packaging_licenses is None, reason='packaging too old')
+def test_normalize_license():
+    pyproject = {'project': {
+        'name': 'test',
+        'version': '1.2.3',
+        'license': 'mit or bsd-3-clause',
+    }}
+    metadata = Metadata.from_pyproject(pyproject, pathlib.Path())
+    assert metadata.license == 'MIT OR BSD-3-Clause'
+
+
+@pytest.mark.skipif(packaging_licenses is None, reason='packaging too old')
+def test_invalid_license():
+    pyproject = {'project': {
+        'name': 'test',
+        'version': '1.2.3',
+        'license': 'Foo',
+    }}
+    with pytest.raises(packaging_licenses.InvalidLicenseExpression, match='Unknown license: \'foo\''):
+        Metadata.from_pyproject(pyproject, pathlib.Path())
diff --git a/tests/test_project.py b/tests/test_project.py
index d2a4e5e47..693fec6aa 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -19,7 +19,7 @@

 import mesonpy

-from .conftest import in_git_repo_context, package_dir
+from .conftest import MESON_VERSION, in_git_repo_context, metadata, package_dir

 def test_unsupported_python_version(package_unsupported_python_version):
@@ -40,6 +40,135 @@ def test_missing_dynamic_version(package_missing_dynamic_version):
             pass

+@pytest.mark.skipif(MESON_VERSION < (1, 6, 0), reason='meson too old')
+@pytest.mark.filterwarnings('ignore:canonicalization and validation of license expression')
+def test_meson_build_metadata(tmp_path):
+    tmp_path.joinpath('pyproject.toml').write_text(textwrap.dedent('''
+        [build-system]
+        build-backend = 'mesonpy'
+        requires = ['meson-python']
+    '''), encoding='utf8')
+
+    tmp_path.joinpath('meson.build').write_text(textwrap.dedent('''
+        project('test', version: '1.2.3', license: 'MIT', license_files: 'LICENSE')
+    '''), encoding='utf8')
+
+    tmp_path.joinpath('LICENSE').write_text('')
+
+    p = mesonpy.Project(tmp_path, tmp_path / 'build')
+
+    assert metadata(bytes(p._metadata.as_rfc822())) == metadata(textwrap.dedent('''\
+        Metadata-Version: 2.4
+        Name: test
+        Version: 1.2.3
+        License-Expression: MIT
+        License-File: LICENSE
+    '''))
+
+
+@pytest.mark.skipif(MESON_VERSION < (1, 6, 0), reason='meson too old')
+@pytest.mark.filterwarnings('ignore:canonicalization and validation of license expression')
+def test_dynamic_license(tmp_path):
+    tmp_path.joinpath('pyproject.toml').write_text(textwrap.dedent('''
+        [build-system]
+        build-backend = 'mesonpy'
+        requires = ['meson-python']
+
+        [project]
+        name = 'test'
+        version = '1.0.0'
+        dynamic = ['license']
+    '''), encoding='utf8')
+
+    tmp_path.joinpath('meson.build').write_text(textwrap.dedent('''
+        project('test', license: 'MIT')
+    '''), encoding='utf8')
+
+    p = mesonpy.Project(tmp_path, tmp_path / 'build')
+
+    assert metadata(bytes(p._metadata.as_rfc822())) == metadata(textwrap.dedent('''\
+        Metadata-Version: 2.4
+        Name: test
+        Version: 1.0.0
+        License-Expression: MIT
+    '''))
+
+
+@pytest.mark.skipif(MESON_VERSION < (1, 6, 0), reason='meson too old')
+@pytest.mark.filterwarnings('ignore:canonicalization and validation of license expression')
+def test_dynamic_license_list(tmp_path):
+    tmp_path.joinpath('pyproject.toml').write_text(textwrap.dedent('''
+        [build-system]
+        build-backend = 'mesonpy'
+        requires = ['meson-python']
+
+        [project]
+        name = 'test'
+        version = '1.0.0'
+        dynamic = ['license']
+    '''), encoding='utf8')
+
+    tmp_path.joinpath('meson.build').write_text(textwrap.dedent('''
+        project('test', license: ['MIT', 'BSD-3-Clause'])
+    '''), encoding='utf8')
+
+    with pytest.raises(pyproject_metadata.ConfigurationError, match='Using a list of strings for the license'):
+        mesonpy.Project(tmp_path, tmp_path / 'build')
+
+
+@pytest.mark.skipif(MESON_VERSION < (1, 6, 0), reason='meson too old')
+@pytest.mark.filterwarnings('ignore:canonicalization and validation of license expression')
+def test_dynamic_license_missing(tmp_path):
+    tmp_path.joinpath('pyproject.toml').write_text(textwrap.dedent('''
+        [build-system]
+        build-backend = 'mesonpy'
+        requires = ['meson-python']
+
+        [project]
+        name = 'test'
+        version = '1.0.0'
+        dynamic = ['license']
+    '''), encoding='utf8')
+
+    tmp_path.joinpath('meson.build').write_text(textwrap.dedent('''
+        project('test')
+    '''), encoding='utf8')
+
+    with pytest.raises(pyproject_metadata.ConfigurationError, match='Field "license" declared as dynamic but'):
+        mesonpy.Project(tmp_path, tmp_path / 'build')
+
+
+@pytest.mark.skipif(MESON_VERSION < (1, 6, 0), reason='meson too old')
+@pytest.mark.filterwarnings('ignore:canonicalization and validation of license expression')
+def test_dynamic_license_files(tmp_path):
+    tmp_path.joinpath('pyproject.toml').write_text(textwrap.dedent('''
+        [build-system]
+        build-backend = 'mesonpy'
+        requires = ['meson-python']
+
+        [project]
+        name = 'test'
+        version = '1.0.0'
+        dynamic = ['license', 'license-files']
+    '''), encoding='utf8')
+
+    tmp_path.joinpath('meson.build').write_text(textwrap.dedent('''
+        project('test', license: 'MIT', license_files: ['LICENSE'])
+    '''), encoding='utf8')
+
+    tmp_path.joinpath('LICENSE').write_text('')
+
+    p = mesonpy.Project(tmp_path, tmp_path / 'build')
+
+    assert metadata(bytes(p._metadata.as_rfc822())) == metadata(textwrap.dedent('''\
+        Metadata-Version: 2.4
+        Name: test
+        Version: 1.0.0
+        License-Expression: MIT
+        License-File: LICENSE
+    '''))
+
+
 def test_user_args(package_user_args, tmp_path, monkeypatch):
     project_run = mesonpy.Project._run
     cmds = []
diff --git a/tests/test_sdist.py b/tests/test_sdist.py
index ac56bcf06..a98b27d10 100644
--- a/tests/test_sdist.py
+++ b/tests/test_sdist.py
@@ -16,7 +16,7 @@
 from .conftest import in_git_repo_context, metadata

-def test_no_pep621(sdist_library):
+def test_meson_build_metadata(sdist_library):
     with tarfile.open(sdist_library, 'r:gz') as sdist:
         sdist_pkg_info = sdist.extractfile('library-1.0.0/PKG-INFO').read()

@@ -27,20 +27,14 @@ def test_no_pep621(sdist_library):
     '''))

-def test_pep621(sdist_full_metadata):
+def test_pep621_metadata(sdist_full_metadata):
     with tarfile.open(sdist_full_metadata, 'r:gz') as sdist:
         sdist_pkg_info = sdist.extractfile('full_metadata-1.2.3/PKG-INFO').read()

     meta = metadata(sdist_pkg_info)
-
-    # pyproject-metadata prior to 0.9.0 strips trailing newlines
+    # Including the trailing newline in the expected value is inconvenient.
     meta['license'] = meta['license'].rstrip()

-    # pyproject-metadata 0.9.0 and later does not emit Home-Page
-    meta.pop('home_page', None)
-    # nor normalizes Project-URL keys
-    meta['project_urls'] = {k.lower(): v for k, v in meta['project_urls'].items()}
-
     assert meta == metadata(textwrap.dedent('''\
         Metadata-Version: 2.1
         Name: full-metadata
diff --git a/tests/test_wheel.py b/tests/test_wheel.py
index 1b8198235..20ae0ba72 100644
--- a/tests/test_wheel.py
+++ b/tests/test_wheel.py
@@ -6,7 +6,6 @@
 import re
 import shutil
 import stat
-import subprocess
 import sys
 import sysconfig
 import textwrap
@@ -17,12 +16,9 @@

 import mesonpy

-from .conftest import adjust_packaging_platform_tag
+from .conftest import MESON_VERSION, adjust_packaging_platform_tag, metadata

-_meson_ver_str = subprocess.run(['meson', '--version'], check=True, stdout=subprocess.PIPE, text=True).stdout
-MESON_VERSION = tuple(map(int, _meson_ver_str.split('.')[:3]))
-
 EXT_SUFFIX = sysconfig.get_config_var('EXT_SUFFIX')
 if sys.version_info <= (3, 8, 7):
     if MESON_VERSION >= (0, 99):
@@ -140,6 +136,28 @@ def test_contents_license_file(wheel_license_file):
     assert artifact.read('license_file-1.0.0.dist-info/LICENSE.custom').rstrip() == b'Hello!'

+@pytest.mark.filterwarnings('ignore:canonicalization and validation of license expression')
+def test_license_pep639(wheel_license_pep639):
+    artifact = wheel.wheelfile.WheelFile(wheel_license_pep639)
+
+    assert wheel_contents(artifact) == {
+        'license_pep639-1.0.0.dist-info/METADATA',
+        'license_pep639-1.0.0.dist-info/RECORD',
+        'license_pep639-1.0.0.dist-info/WHEEL',
+        'license_pep639-1.0.0.dist-info/licenses/LICENSES/BSD-3-Clause.txt',
+        'license_pep639-1.0.0.dist-info/licenses/LICENSES/MIT.txt',
+    }
+
+    assert metadata(artifact.read('license_pep639-1.0.0.dist-info/METADATA')) == metadata(textwrap.dedent('''\
+        Metadata-Version: 2.4
+        Name: license-pep639
+        Version: 1.0.0
+        License-Expression: MIT OR BSD-3-Clause
+        License-File: LICENSES/BSD-3-Clause.txt
+        License-File: LICENSES/MIT.txt
+    '''))
+
+
 @pytest.mark.skipif(sys.platform not in {'linux', 'darwin', 'sunos5'}, reason='Not supported on this platform')
 def test_contents(package_library, wheel_library):
     artifact = wheel.wheelfile.WheelFile(wheel_library)

Allow `license` to be listed in the dynamic section
Meson already has `project(license:)`. Would it be possible to autodetect it dynamically? For instace, I have `'Apache-2.0 OR MIT'` as the value in `project()`. I would like this to populate the `license = { text = "XXX" }` metadata like this project already does with `version`.

Thanks for the suggestion! However, I think the semantics are a little bit different, `XXX` in your example would be the actual license text, so "Apache-2.0 OR MIT" wouldn't be the correct value. There's no way to specify that currently, but hopefully there will be with [PEP 639](https://peps.python.org/pep-0639/), which adds a `License-Expression` core metadata field and assigns the value of `project.license` in pyproject.toml to the expression. So, this is another issue where we are blocked until that PEP, or an alternative, lands.
It's unfortunate this wasn't thought of earlier when the pyproject.toml was designed. Seems so fundamental, but hopefully the PEP is accepted.

For now, I'll just leave it as is since I see no better way to express dual licensed software.
My recommendation for that case would be to have a single license file with both licenses, and specifying it in `pyproject.tml`. But I agree, it's unfortunate, hopefully the PEP will land though and fix this.
Unfortunately that wouldn't be REUSE compliant from what I understand.

https://reuse.software/
I didn't know about REUSE, thank you for pointing it out. I've only skimmed it a bit, but I can't tell how it wouldn't be compliant? You can still have the SPDX headers, you'd just have an extra license file for the Python tooling to read. In fact, using the REUSE spec you should even be able to automatically generate this file! Am I missing something here? :rofl:
Hmm `touch LICENSE` in the root doesn't seem to cause the tool to fail. Oh well. Just throw REUSE on the pile of reasons that PEP should be advanced.
Agreed. I have pinged the discourse thread. Let's see if we can get it to move forward.

(cc @CAM-Gerlach)
Thanks for your efforts.
Sorry, finishing what is hopefully the last major update got stuck beyond a pile of other things that came up over the past few months, but I've now got most of the next rewrite complete and it's near the top of my priority list to finish, hopefully in the next few days :crossed_fingers: I can also mention REUSE compliance and this issue in the rationale and on the thread.
@CAM-Gerlach no problem whatsoever. At least someone thought about it in the first place!
Thanks, but to be fair that person with the original idea wasn't me—credit for that is due to @pombredanne
Could one of y'all link to the discourse thread where I presume discussion of the referenced PEP is? I am not familiar with the Python PEP process.
It's here :blush:

https://discuss.python.org/t/pep-639-round-2-improving-license-clarity-with-better-package-metadata
Meson 1.1 now has [`license_files`](https://mesonbuild.com/Reference-manual_functions.html#project_license_files), which it would be nice to automatically distribute as a set of license files (especially if you have a dependency subproject that may be bundled in a wheel, but not if built in, e.g.,  a Linux distro).
Thanks @QuLogic. That does look potentially useful, especially for the subproject case you mention, since that cannot be done from `pyproject.toml`. I think we're still waiting for PEP 639 to land first though? Not sure what we'd do with `license_files` right now.
You can install them to *.dist-info/licenses, regardless of the availability/usefulness for `Metadata-Version: 2.1`
PEP639 is now provisional, i.e., waiting for two build backends to implement it. meson-python could be one of those two.


diff --git a/django/forms/fields.py b/django/forms/fields.py
index 202a6d72c878..4bd9c352f270 100644
--- a/django/forms/fields.py
+++ b/django/forms/fields.py
@@ -95,6 +95,7 @@ class Field:
         "required": _("This field is required."),
     }
     empty_values = list(validators.EMPTY_VALUES)
+    bound_field_class = None

     def __init__(
         self,
@@ -111,6 +112,7 @@ def __init__(
         disabled=False,
         label_suffix=None,
         template_name=None,
+        bound_field_class=None,
     ):
         # required -- Boolean that specifies whether the field is required.
         #             True by default.
@@ -135,11 +137,13 @@ def __init__(
         #             is its widget is shown in the form but not editable.
         # label_suffix -- Suffix to be added to the label. Overrides
         #                 form's label_suffix.
+        # bound_field_class -- BoundField class to use in Field.get_bound_field.
         self.required, self.label, self.initial = required, label, initial
         self.show_hidden_initial = show_hidden_initial
         self.help_text = help_text
         self.disabled = disabled
         self.label_suffix = label_suffix
+        self.bound_field_class = bound_field_class or self.bound_field_class
         widget = widget or self.widget
         if isinstance(widget, type):
             widget = widget()
@@ -251,7 +255,10 @@ def get_bound_field(self, form, field_name):
         Return a BoundField instance that will be used when accessing the form
         field in a template.
         """
-        return BoundField(form, self, field_name)
+        bound_field_class = (
+            self.bound_field_class or form.bound_field_class or BoundField
+        )
+        return bound_field_class(form, self, field_name)

     def __deepcopy__(self, memo):
         result = copy.copy(self)
diff --git a/django/forms/forms.py b/django/forms/forms.py
index 614f99039585..844f15f9f27f 100644
--- a/django/forms/forms.py
+++ b/django/forms/forms.py
@@ -68,6 +68,8 @@ class BaseForm(RenderableFormMixin):
     template_name_ul = "django/forms/ul.html"
     template_name_label = "django/forms/label.html"

+    bound_field_class = None
+
     def __init__(
         self,
         data=None,
@@ -81,6 +83,7 @@ def __init__(
         field_order=None,
         use_required_attribute=None,
         renderer=None,
+        bound_field_class=None,
     ):
         self.is_bound = data is not None or files is not None
         self.data = MultiValueDict() if data is None else data
@@ -124,6 +127,12 @@ def __init__(
                     renderer = renderer()
         self.renderer = renderer

+        self.bound_field_class = (
+            bound_field_class
+            or self.bound_field_class
+            or getattr(self.renderer, "bound_field_class", None)
+        )
+
     def order_fields(self, field_order):
         """
         Rearrange the fields according to field_order.
diff --git a/django/forms/renderers.py b/django/forms/renderers.py
index baf8f7450765..20eaf265df05 100644
--- a/django/forms/renderers.py
+++ b/django/forms/renderers.py
@@ -21,6 +21,8 @@ class BaseRenderer:
     formset_template_name = "django/forms/formsets/div.html"
     field_template_name = "django/forms/field.html"

+    bound_field_class = None
+
     def get_template(self, template_name):
         raise NotImplementedError("subclasses must implement get_template()")

diff --git a/docs/ref/forms/api.txt b/docs/ref/forms/api.txt
index 1b1ecbec6cf8..9b827ca69acc 100644
--- a/docs/ref/forms/api.txt
+++ b/docs/ref/forms/api.txt
@@ -822,6 +822,9 @@ classes, as needed. The HTML will look something like:
     >>> f["subject"].legend_tag(attrs={"class": "foo"})
     <legend for="id_subject" class="foo required">Subject:</legend>

+You may further modify the rendering of form rows by using a
+:ref:`custom BoundField <custom-boundfield>`.
+
 .. _ref-forms-api-configuring-label:

 Configuring form elements' HTML ``id`` attributes and ```` tags
@@ -1149,6 +1152,12 @@ they're not the only way a form object can be displayed.

    The ``__str__()`` method of this object displays the HTML for this field.

+   You can use :attr:`.Form.bound_field_class` and
+   :attr:`.Field.bound_field_class` to specify a different ``BoundField`` class
+   per form or per field, respectively.
+
+   See :ref:`custom-boundfield` for examples of overriding a ``BoundField``.
+
 To retrieve a single ``BoundField``, use dictionary lookup syntax on your form
 using the field's name as the key:

@@ -1488,23 +1497,34 @@ Methods of ``BoundField``
         >>> print(bound_form["subject"].value())
         hi

+.. _custom-boundfield:
+
 Customizing ``BoundField``
 ==========================

-If you need to access some additional information about a form field in a
-template and using a subclass of :class:`~django.forms.Field` isn't
-sufficient, consider also customizing :class:`~django.forms.BoundField`.
+.. attribute:: Form.bound_field_class

-A custom form field can override ``get_bound_field()``:
+.. versionadded:: 5.2

-.. method:: Field.get_bound_field(form, field_name)
+Define a custom :class:`~django.forms.BoundField` class to use when rendering
+the form. This takes precedence over the project-level
+:attr:`.BaseRenderer.bound_field_class` (along with a custom
+:setting:`FORM_RENDERER`), but can be overridden by the field-level
+:attr:`.Field.bound_field_class`.

-    Takes an instance of :class:`~django.forms.Form` and the name of the field.
-    The return value will be used when accessing the field in a template. Most
-    likely it will be an instance of a subclass of
-    :class:`~django.forms.BoundField`.
+If not defined as a class variable, ``bound_field_class`` can be set via the
+``bound_field_class`` argument in the :class:`Form` or :class:`Field`
+constructor.

-If you have a ``GPSCoordinatesField``, for example, and want to be able to
+For compatibility reasons, a custom form field can still override
+:meth:`.Field.get_bound_field()` to use a custom class, though any of the
+previous options are preferred.
+
+You may want to use a custom :class:`.BoundField` if you need to access some
+additional information about a form field in a template and using a subclass of
+:class:`~django.forms.Field` isn't sufficient.
+
+For example, if you have a ``GPSCoordinatesField``, and want to be able to
 access additional information about the coordinates in a template, this could
 be implemented as follows::

@@ -1523,12 +1543,74 @@ be implemented as follows::

     class GPSCoordinatesField(Field):
-        def get_bound_field(self, form, field_name):
-            return GPSCoordinatesBoundField(form, self, field_name)
+        bound_field_class = GPSCoordinatesBoundField

 Now you can access the country in a template with
 ``{{ form.coordinates.country }}``.

+You may also want to customize the default form field template rendering. For
+example, you can override :meth:`.BoundField.label_tag` to add a custom class::
+
+   class StyledLabelBoundField(BoundField):
+       def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+           attrs = attrs or {}
+           attrs["class"] = "wide"
+           return super().label_tag(contents, attrs, label_suffix, tag)
+
+
+   class UserForm(forms.Form):
+       bound_field_class = StyledLabelBoundField
+       name = CharField()
+
+This would update the default form rendering:
+
+.. code-block:: pycon
+
+   >>> f = UserForm()
+   >>> print(f["name"].label_tag)
+   Name:
+
+To add a CSS class to the wrapping HTML element of all fields, a ``BoundField``
+can be overridden to return a different collection of CSS classes::
+
+    class WrappedBoundField(BoundField):
+        def css_classes(self, extra_classes=None):
+            parent_css_classes = super().css_classes(extra_classes)
+            return f"field-class {parent_css_classes}".strip()
+
+
+    class UserForm(forms.Form):
+        bound_field_class = WrappedBoundField
+        name = CharField()
+
+This would update the form rendering as follows:
+
+.. code-block:: pycon
+
+   >>> f = UserForm()
+   >>> print(f)
+
Name:<input type="text" name="name" required id="id_name">

+
+Alternatively, to override the ``BoundField`` class at the project level,
+:attr:`.BaseRenderer.bound_field_class` can be defined on a custom
+:setting:`FORM_RENDERER`:
+
+.. code-block:: python
+    :caption: ``mysite/renderers.py``
+
+    from django.forms.renderers import DjangoTemplates
+
+    from .forms import CustomBoundField
+
+
+    class CustomRenderer(DjangoTemplates):
+        bound_field_class = CustomBoundField
+
+.. code-block:: python
+    :caption: ``settings.py``
+
+    FORM_RENDERER = "mysite.renderers.CustomRenderer"
+
 .. _binding-uploaded-files:

 Binding uploaded files to a form
diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt
index c0ab4f2434c5..7ae5c121eb99 100644
--- a/docs/ref/forms/fields.txt
+++ b/docs/ref/forms/fields.txt
@@ -397,6 +397,16 @@ default this value is set to ``"django/forms/field.html"``. Can be changed per
 field by overriding this attribute or more generally by overriding the default
 template, see also :ref:`overriding-built-in-field-templates`.

+``bound_field_class``
+---------------------
+
+.. attribute:: Field.bound_field_class
+
+.. versionadded:: 5.2
+
+The ``bound_field_class`` attribute allows a per-field override of
+:attr:`.Form.bound_field_class`.
+
 Checking if the field data has changed
 ======================================

@@ -1635,4 +1645,14 @@ only requirements are that it implement a ``clean()`` method and that its
 ``label``, ``initial``, ``widget``, ``help_text``).

 You can also customize how a field will be accessed by overriding
-:meth:`~django.forms.Field.get_bound_field()`.
+:attr:`~django.forms.Field.bound_field_class` or override
+:meth:`.Field.get_bound_field()` if you need more flexibility when creating
+the ``BoundField``:
+
+.. method:: Field.get_bound_field(form, field_name)
+
+    Takes an instance of :class:`~django.forms.Form` and the name of the field.
+    The returned :class:`.BoundField` instance will be used when accessing the
+    field in a template.
+
+See :ref:`custom-boundfield` for examples of overriding a ``BoundField``.
diff --git a/docs/ref/forms/renderers.txt b/docs/ref/forms/renderers.txt
index e527a70c5771..f52534651abd 100644
--- a/docs/ref/forms/renderers.txt
+++ b/docs/ref/forms/renderers.txt
@@ -65,6 +65,18 @@ should return a rendered templates (as a string) or raise

         Defaults to ``"django/forms/field.html"``

+    .. attribute:: bound_field_class
+
+        .. versionadded:: 5.2
+
+        The default class used to represent form fields across the project.
+
+        Defaults to :class:`.BoundField` class.
+
+        This can be customized further using :attr:`.Form.bound_field_class`
+        for per-form overrides, or :attr:`.Field.bound_field_class` for
+        per-field overrides.
+
     .. method:: get_template(template_name)

         Subclasses must implement this method with the appropriate template
diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt
index a65306d0a19b..5b04608cad34 100644
--- a/docs/releases/5.2.txt
+++ b/docs/releases/5.2.txt
@@ -70,6 +70,62 @@ to be a ``CompositePrimaryKey``::

 See :doc:`/topics/composite-primary-key` for more details.

+Simplified override of :class:`~django.forms.BoundField`
+--------------------------------------------------------
+
+Prior to version 5.2, overriding :meth:`.Field.get_bound_field()` was the only
+option to use a custom :class:`~django.forms.BoundField`. Django now supports
+specifying the following attributes to customize form rendering:
+
+* :attr:`.BaseRenderer.bound_field_class` at the project level,
+* :attr:`.Form.bound_field_class` at the form level, and
+* :attr:`.Field.bound_field_class` at the field level.
+
+For example, to customize the ``BoundField`` of a ``Form`` class::
+
+    from django.forms import Form
+
+
+    class CustomBoundField(forms.BoundField):
+
+        custom_class = "custom"
+
+        def css_classes(self, extra_classes=None):
+            result = super().css_classes(extra_classes)
+            if self.custom_class not in result:
+                result += f" {self.custom_class}"
+            return result.strip()
+
+
+    class CustomForm(forms.Form):
+        bound_field_class = CustomBoundField
+
+        name = forms.CharField(
+            label="Your Name",
+            max_length=100,
+            required=False,
+            widget=forms.TextInput(attrs={"class": "name-input-class"}),
+        )
+        email = forms.EmailField(label="Your Email")
+
+
+When rendering a ``CustomForm`` instance, the following HTML is included:
+
+.. code:: html
+
+

+      Your Name:
+      <input type="text" name="name" class="name-input-class" maxlength="100" id="id_name">
+

+
+

+      Your Email:
+      <input type="email" name="email" maxlength="320" required="" id="id_email">
+

+
+
+See :ref:`custom-boundfield` for more details about this feature.
+
 Minor features
 --------------

diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py
index b41424d43d64..20c86754c6e5 100644
--- a/tests/forms_tests/tests/test_forms.py
+++ b/tests/forms_tests/tests/test_forms.py
@@ -8,6 +8,7 @@
 from django.core.validators import MaxValueValidator, RegexValidator
 from django.forms import (
     BooleanField,
+    BoundField,
     CharField,
     CheckboxSelectMultiple,
     ChoiceField,
@@ -4971,6 +4972,22 @@ def test_get_context_errors(self):
         context = form.get_context()
         self.assertEqual(context["errors"].renderer, custom)

+    def test_boundfield_fallback(self):
+        class RendererWithoutBoundFieldClassAttribute:
+            form_template_name = "django/forms/div.html"
+            formset_template_name = "django/forms/formsets/div.html"
+            field_template_name = "django/forms/field.html"
+
+            def render(self, template_name, context, request=None):
+                return "Nice"
+
+        class UserForm(Form):
+            name = CharField()
+
+        form = UserForm(renderer=RendererWithoutBoundFieldClassAttribute())
+        self.assertIsInstance(form["name"], BoundField)
+        self.assertEqual(form["name"].as_field_group(), "Nice")
+

 class TemplateTests(SimpleTestCase):
     def test_iterate_radios(self):
@@ -5473,3 +5490,146 @@ class CustomFrameworkForm(FrameworkForm):
             'Name:'
             '<legend class="required">Language:</legend>',
         )
+
+
+class BoundFieldWithoutColon(BoundField):
+    def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+        return super().label_tag(
+            contents=contents, attrs=attrs, label_suffix="", tag=None
+        )
+
+
+class BoundFieldWithTwoColons(BoundField):
+    def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+        return super().label_tag(
+            contents=contents, attrs=attrs, label_suffix="::", tag=None
+        )
+
+
+class BoundFieldWithCustomClass(BoundField):
+    def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+        attrs = attrs or {}
+        attrs["class"] = "custom-class"
+        return super().label_tag(contents, attrs, label_suffix, tag)
+
+
+class BoundFieldWithWrappingClass(BoundField):
+    def css_classes(self, extra_classes=None):
+        parent_classes = super().css_classes(extra_classes)
+        return f"field-class {parent_classes}"
+
+
+class BoundFieldOverrideRenderer(DjangoTemplates):
+    bound_field_class = BoundFieldWithoutColon
+
+
+@override_settings(
+    FORM_RENDERER="forms_tests.tests.test_forms.BoundFieldOverrideRenderer"
+)
+class CustomBoundFieldTest(SimpleTestCase):
+    def test_renderer_custom_bound_field(self):
+        t = Template("{{ form }}")
+        html = t.render(Context({"form": Person()}))
+        expected = """
+
First name
+            <input type="text" name="first_name" required
+            id="id_first_name">

+
Last name
+            <input type="text" name="last_name" required
+            id="id_last_name">

+            Birthday
+            <input type="text" name="birthday" required
+            id="id_birthday">
"""
+        self.assertHTMLEqual(html, expected)
+
+    def test_form_custom_boundfield(self):
+        class CustomBoundFieldPerson(Person):
+            bound_field_class = BoundFieldWithTwoColons
+
+        with self.subTest("form's BoundField takes over renderer's BoundField"):
+            t = Template("{{ form }}")
+            html = t.render(Context({"form": CustomBoundFieldPerson()}))
+            expected = """
+
First name::
+                <input type="text" name="first_name" required
+                id="id_first_name">

+
Last name::
+                <input type="text" name="last_name" required
+                id="id_last_name">

+                Birthday::
+                <input type="text" name="birthday" required
+                id="id_birthday">
"""
+            self.assertHTMLEqual(html, expected)
+
+        with self.subTest("Constructor argument takes over class property"):
+            t = Template("{{ form }}")
+            html = t.render(
+                Context(
+                    {
+                        "form": CustomBoundFieldPerson(
+                            bound_field_class=BoundFieldWithCustomClass
+                        )
+                    }
+                )
+            )
+            expected = """
+
First name:
+                <input type="text" name="first_name" required
+                id="id_first_name">

+
Last name:
+                <input type="text" name="last_name" required
+                id="id_last_name">

+                Birthday:
+                <input type="text" name="birthday" required
+                id="id_birthday">
"""
+            self.assertHTMLEqual(html, expected)
+
+        with self.subTest("Overriding css_classes works as expected"):
+            t = Template("{{ form }}")
+            html = t.render(
+                Context(
+                    {
+                        "form": CustomBoundFieldPerson(
+                            bound_field_class=BoundFieldWithWrappingClass
+                        )
+                    }
+                )
+            )
+            expected = """
+
First name:
+                <input type="text" name="first_name" required
+                id="id_first_name">

+
Last name:
+                <input type="text" name="last_name" required
+                id="id_last_name">

+                Birthday:
+                <input type="text" name="birthday" required
+                id="id_birthday">
"""
+            self.assertHTMLEqual(html, expected)
+
+    def test_field_custom_bound_field(self):
+        class BoundFieldWithTwoColonsCharField(CharField):
+            bound_field_class = BoundFieldWithTwoColons
+
+        class CustomFieldBoundFieldPerson(Person):
+            bound_field_class = BoundField
+
+            first_name = BoundFieldWithTwoColonsCharField()
+            last_name = BoundFieldWithTwoColonsCharField(
+                bound_field_class=BoundFieldWithCustomClass
+            )
+
+        html = Template("{{ form }}").render(
+            Context({"form": CustomFieldBoundFieldPerson()})
+        )
+        expected = """
+
First name::
+            <input type="text" name="first_name" required
+            id="id_first_name">

+
Last name:
+            <input type="text" name="last_name" required
+            id="id_last_name">

+            Birthday:
+            <input type="text" name="birthday" required
+            id="id_birthday">
"""
+        self.assertHTMLEqual(html, expected)

Allow overriding BoundField class on forms and fields
Description

It would be useful if there was an easy way to add CSS classes to the HTML element which is generated when rendering a BoundField. I propose adding field_css_class, similar to required_css_class and error_css_class. https://docs.djangoproject.com/en/5.0/ref/forms/api/#styling-required-or-erroneous-form-rows
Something like this has already been rejected in the past here: https://code.djangoproject.com/ticket/29189 . As I understand it the reason for the rejection was the upcoming templates-based form rendering and not really the feature itself. I may have missed the point though.
However, I still think there'd be some value in this even though we have templates-based renderers now. The div (in the case of the div renderer) is generated here: https://github.com/django/django/blob/main/django/forms/templates/django/forms/div.html , so it's not just about overriding django/forms/field.html if you want to avoid adding another nesting level. Also, css_classes exists and is documented.
Code-wise I propose extending the css_classes method as follows:
https://github.com/django/django/blob/e2428292abaca4758a7508175d31667fe2dff57c/django/forms/boundfield.py#L220-L231
		 extra_classes = set(extra_classes or [])
+		if hasattr(self.form, "field_css_class"):
+			extra_classes.add(self.form.field_css_class)
		 if self.errors and hasattr(self.form, "error_css_class"):
I'm willing to do the work if this is accepted.
Allow overriding BoundField class on forms and fields
Description

It would be useful if there was an easy way to add CSS classes to the HTML element which is generated when rendering a BoundField. I propose adding field_css_class, similar to required_css_class and error_css_class. https://docs.djangoproject.com/en/5.0/ref/forms/api/#styling-required-or-erroneous-form-rows
Something like this has already been rejected in the past here: https://code.djangoproject.com/ticket/29189 . As I understand it the reason for the rejection was the upcoming templates-based form rendering and not really the feature itself. I may have missed the point though.
However, I still think there'd be some value in this even though we have templates-based renderers now. The div (in the case of the div renderer) is generated here: https://github.com/django/django/blob/main/django/forms/templates/django/forms/div.html , so it's not just about overriding django/forms/field.html if you want to avoid adding another nesting level. Also, css_classes exists and is documented.
Code-wise I propose extending the css_classes method as follows:
https://github.com/django/django/blob/e2428292abaca4758a7508175d31667fe2dff57c/django/forms/boundfield.py#L220-L231
		 extra_classes = set(extra_classes or [])
+		if hasattr(self.form, "field_css_class"):
+			extra_classes.add(self.form.field_css_class)
		 if self.errors and hasattr(self.form, "error_css_class"):
I'm willing to do the work if this is accepted.