# Copyright (C) 2025 The Qt Company Ltd. # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 import importlib.util import logging import sys def setup_log() -> logging.Logger: logging.basicConfig(format="[%(levelname)s]: %(message)s", level=logging.INFO) log_object = logging.getLogger("qt-cyclonedx-generator") log_object.setLevel(logging.INFO) return log_object log = setup_log() def import_tomllib(): """Import the TOML library with fallback options for different Python versions. Stock toml lib is available on Python 3.11+, if not found try the tomli library, and if that isn't found try to use the pip vendored tomli library. Returns: Tuple of (tomllib_module, success_flag) """ if sys.version_info >= (3, 11): import tomllib as tomllib_local return tomllib_local, True else: try: import tomli as tomllib_local return tomllib_local, True except ModuleNotFoundError: try: # noinspection PyProtectedMember import pip._vendor.tomli as tomllib_local return tomllib_local, True except ModuleNotFoundError: return None, False tomllib, tomllib_available = import_tomllib() def check_package_dependencies() -> None: """Check if required package dependencies are available.""" missing_packages = [] if not tomllib_available: missing_packages.append("tomli") if importlib.util.find_spec("cyclonedx") is None: missing_packages.append("cyclonedx-python-lib") if importlib.util.find_spec("packageurl") is None: missing_packages.append("packageurl-python") if missing_packages: log.error(f"Missing required dependencies: {', '.join(missing_packages)}") log.error(f"Please install with: `pip install {' '.join(missing_packages)}`") sys.exit(1) check_package_dependencies() import argparse from datetime import datetime, timezone from license_expression import get_spdx_licensing from pathlib import Path from packageurl import PackageURL from typing import ( TYPE_CHECKING, Optional, Any, TypedDict, Union, cast, Callable, ) from uuid import UUID from cyclonedx.factory.license import LicenseFactory from cyclonedx.exception import MissingOptionalDependencyException from cyclonedx.model import ( XsUri, ExternalReference, ExternalReferenceType, Property, AttachedText, ) from cyclonedx.model.bom import Bom from cyclonedx.model.component import Component, ComponentType from cyclonedx.model.contact import OrganizationalEntity, OrganizationalContact from cyclonedx.model.license import LicenseAcknowledgement, LicenseExpression from cyclonedx.model.lifecycle import PredefinedLifecycle, LifecyclePhase from cyclonedx.output.json import JsonV1Dot6 from cyclonedx.schema import SchemaVersion from cyclonedx.validation.json import JsonStrictValidator if TYPE_CHECKING: from cyclonedx.output.json import Json as JsonOutputter class TomlRequiredFieldError(Exception): pass class MissingTomlFileError(Exception): pass class SBOMWriteFailedError(Exception): pass class ValidationFailedError(Exception): pass class ValidationDependencyMissingError(Exception): pass # TypedDict definitions for TOML data structures class CyclonePropertyDict(TypedDict): name: str value: str class LicenseDict(TypedDict): license_id: str text: str class RelationshipDict(TypedDict): # Optional fields relationship_from: str relationship_type: str relationship_to: str relationship_comment: str class RootComponentDict(TypedDict): name: str spdx_id: str # Optional fields (since TypedDict doesn't support NotRequired in Python 3.9) # We'll use .get() to access these safely component_type: str version: str description: str download_location: str build_date: str supplier: str supplier_url: str serial_number_uuid: str relationships: list[RelationshipDict] class ComponentDict(TypedDict): name: str spdx_id: str # Optional fields version: str component_type: str copyright: str external_bom_link: str supplier: str purl_list: list[str] cpe_list: list[str] properties: list[CyclonePropertyDict] relationships: list[RelationshipDict] class BuildToolDict(TypedDict): # Optional fields name: str component_type: str version: str description: str class TomlDataDict(TypedDict): root_component: RootComponentDict # Optional fields project_build_tools: list[BuildToolDict] components: list[ComponentDict] licenses: list[LicenseDict] # Type alias for licenses dictionary LicensesDict = dict[str, LicenseDict] def validate_required_field( data: Union[dict[str, Any], TomlDataDict, ComponentDict, RootComponentDict], field: str, context: str, ) -> Any: """Validate that a required field exists in the data dictionary. Args: data: The dictionary to check field: The required field name context: Description of where this validation is happening (for error messages) Returns: The value of the required field Raises: TomlRequiredFieldError: If the required field is missing """ if field not in data: raise TomlRequiredFieldError(f"Missing required field '{field}' in {context}") return data[field] def _parse_build_date(date_str: str) -> datetime: """Parse build date string, handling Python 3.9+ compatibility for Z suffix.""" override_to_utc = False if sys.version_info < (3, 11) and date_str.endswith("Z"): # Remove the Z at the end to support Python 3.9+, because # datetime.fromisoformat learned to parse the Z in 3.11 only. # In that case we need to override the timezone to be UTC manually. date_str = date_str[:-1] override_to_utc = True build_date = datetime.fromisoformat(date_str) if override_to_utc: build_date = build_date.replace(tzinfo=timezone.utc) return build_date def assign_optional_field( source_data: Union[ dict[str, Any], TomlDataDict, ComponentDict, RootComponentDict, BuildToolDict ], target_args: dict[str, Any], source_field: str, target_field: Optional[str] = None, transform: Optional[Callable[[Any], Any]] = None, ) -> None: """Assign an optional field from source data to target args if it exists. Args: source_data: The source dictionary to read from target_args: The target dictionary to write to source_field: The field name in the source data target_field: The field name in the target args (defaults to source_field if None) transform: Optional function to transform the value before assignment """ if target_field is None: target_field = source_field if value := source_data.get(source_field): if transform: value = transform(value) # Only assign if the transformed value is not None if value is not None: target_args[target_field] = value def main() -> None: parser = get_cli_parser() args = get_cli_args(parser) handle_verbose_logging(args.verbose) try: create_cyclone_dx_sbom( args.input_path, args.output_path, args.validate_json, args.validate_json_required, ) except ( TomlRequiredFieldError, MissingTomlFileError, SBOMWriteFailedError, ValidationFailedError, ValidationDependencyMissingError, ) as e: log.error(str(e)) sys.exit(1) def get_cli_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( formatter_class=argparse.RawTextHelpFormatter, description=""" Generates a CycloneDX v1.6 SBOM using intermediate information from a toml file created by Qt's SBOM generator. Example usage: python qt_cyclonedx_generator.py --input-path /qt/qtbase.cdx.toml --output-path /qt/qtbase.cdx.json """, ) parser.add_argument( "-i", "--input-path", type=Path, help="Path to the intermediate toml file.", required=True, ) parser.add_argument( "-o", "--output-path", type=Path, help="Path to the CycloneDX output file.", required=True, ) parser.add_argument( "--validate-json", action="store_true", help="Validate the generated CycloneDX JSON output against the CycloneDX schema.", ) parser.add_argument( "--validate-json-required", action="store_true", help="Error out if validation dependencies are missing.", ) parser.add_argument( "--verbose", action="store_true", help="Enable verbose logging output.", ) return parser def get_cli_args(parser: argparse.ArgumentParser) -> argparse.Namespace: args = parser.parse_args(args=None if sys.argv[1:] else ["--help"]) return args def handle_verbose_logging(verbose: bool) -> None: if verbose: log.setLevel(logging.DEBUG) # Reads a toml file and generates a CycloneDX SBOM file. def create_cyclone_dx_sbom( input_path: Path, output_path: Path, validate_json=False, validate_json_required=False, ) -> None: if not input_path or not input_path.exists(): raise MissingTomlFileError( f"Failed to read toml file. Input file '{input_path}' does not exist." ) cydx_bom = get_cydx_bom() toml_data = read_toml_file(input_path) process_toml(toml_data, cydx_bom) json = write_cydx_sbom(cydx_bom, output_path) if validate_json: validate_cydx_bom(json, validate_json_required) def read_toml_file(input_path: Path) -> TomlDataDict: log.debug(f"Reading toml file from '{input_path}'") with open(input_path, "rb") as f: # Manually read and decode the file contents, because older # tomli versions vendored in pip don't handle this properly. buffer = f.read() toml_str = buffer.decode() return cast(TomlDataDict, cast(Any, tomllib).loads(toml_str)) # Serializes the CycloneDX BOM to JSON and writes it to the output path. def write_cydx_sbom(cydx_bom: Bom, output_path: Path) -> str: json_outputter: JsonOutputter = JsonV1Dot6(cydx_bom) log.debug(f"Serializing CycloneDX BOM to JSON at '{output_path}'") serialized_json = json_outputter.output_as_string(indent=2) try: with open(output_path, "w") as f: f.write(serialized_json) except (OSError, IOError) as e: raise SBOMWriteFailedError(f"Failed to write SBOM to '{output_path}': {e}") return serialized_json def validate_cydx_bom(serialized_json: str, validate_json_required: bool) -> None: log.debug("Validating CycloneDX JSON output against schema v1.6") json_validator = JsonStrictValidator(SchemaVersion.V1_6) try: validation_errors = json_validator.validate_str(serialized_json) if validation_errors: raise ValidationFailedError(f"JSON validation failed: {validation_errors}") except MissingOptionalDependencyException as error: missing_dep_message = ( f"JSON-validation was failed due to missing Python dependency: {error}" ) if validate_json_required: raise ValidationDependencyMissingError(missing_dep_message) else: log.warning(missing_dep_message) def get_cydx_bom() -> Bom: bom = Bom() return bom # Reads toml data created by Qt's SBOM generator and populates the CycloneDX BOM. # At minimum, expects a root component dict, and a list of components. def process_toml(toml_data: TomlDataDict, cydx_bom: Bom) -> None: components: dict[str, Component] = {} log.debug("Processing TOML data.") root_component_object, main_supplier, project_spdx_id = handle_root_component( cydx_bom, toml_data ) components[project_spdx_id] = root_component_object if "project_build_tools" in toml_data: project_build_tools = toml_data["project_build_tools"] handle_project_build_tools(cydx_bom, project_build_tools) external_components_spdx_ids = set() components_toml = [] license_factory = LicenseFactory() # Create one component per CMake target (in spdx it would be a spdx package). if "components" in toml_data: components_toml = toml_data["components"] components_len = len(components_toml) log.debug(f"Processing {components_len} components from TOML data.") licenses_dict: LicensesDict = {} if "licenses" in toml_data: licenses_toml = toml_data["licenses"] for license_entry in licenses_toml: license_id = license_entry["license_id"] licenses_dict[license_id] = license_entry for component_toml in components_toml: process_component( cydx_bom, component_toml, licenses_dict, components, external_components_spdx_ids, main_supplier, license_factory, ) # Register dependencies for each component. log.debug("Processing component dependencies.") for component_toml in components_toml: handle_component_dependencies( cydx_bom, component_toml, components, external_components_spdx_ids, root_component_object, ) log.debug("Processing project relationships.") handle_project_relationships( cydx_bom, toml_data, components, ) # Creates the root component of the BOM. # The CycloneDX root component is mapped to the Qt spdx project package e.g. qtbase. def handle_root_component( cydx_bom: Bom, toml_data: TomlDataDict ) -> tuple[Component, Optional[OrganizationalEntity], str]: root_components_args: dict[str, Any] = {} root_component = validate_required_field(toml_data, "root_component", "TOML data") error_context = "root_component" project_name = validate_required_field(root_component, "name", error_context) root_components_args["name"] = project_name project_spdx_id = validate_required_field(root_component, "spdx_id", error_context) root_components_args["bom_ref"] = project_spdx_id # Process optional fields assign_optional_field(root_component, root_components_args, "version") assign_optional_field(root_component, root_components_args, "description") root_components_args["type"] = get_component_type_for_str( root_component.get("component_type", "framework") ) # Process optional fields with transformations assign_optional_field( root_component, root_components_args, source_field="download_location", target_field="external_references", transform=lambda url: [ ExternalReference( type=ExternalReferenceType.DISTRIBUTION, # pyright: ignore [reportCallIssue] url=XsUri(url), # pyright: ignore [reportCallIssue] ) ], ) if project_build_date_str := root_component.get("build_date"): project_build_date = _parse_build_date(project_build_date_str) cydx_bom.metadata.timestamp = project_build_date # pyright: ignore [reportAttributeAccessIssue] if serial_number_uuid := root_component.get("serial_number_uuid"): cydx_bom.serial_number = UUID(serial_number_uuid) # pyright: ignore [reportAttributeAccessIssue] project_supplier = root_component.get("supplier") project_supplier_url = root_component.get("supplier_url") # TODO: This is currently not supplied by the build system API. project_supplier_email = root_component.get("supplier_email") main_supplier = create_organization_entity(project_supplier, project_supplier_url) main_author = create_organization_contact(project_supplier, project_supplier_email) if main_supplier: cydx_bom.metadata.manufacturer = main_supplier # pyright: ignore [reportAttributeAccessIssue] cydx_bom.metadata.supplier = main_supplier # pyright: ignore [reportAttributeAccessIssue] # FOSSA complains if author is not set. cydx_bom.metadata.authors = [main_author] # pyright: ignore [reportAttributeAccessIssue] root_components_args["supplier"] = main_supplier root_components_args["manufacturer"] = main_supplier root_component_object = Component(**root_components_args) cydx_bom.metadata.component = root_component_object # pyright: ignore [reportAttributeAccessIssue] cydx_bom.metadata.lifecycles = [PredefinedLifecycle(phase=LifecyclePhase.BUILD)] # pyright: ignore [reportAttributeAccessIssue, reportCallIssue] root_component_name_str = f"{root_component_object.name}" # pyright: ignore [reportAttributeAccessIssue] root_component_version_str = f"(version {root_component_object.version})" # pyright: ignore [reportAttributeAccessIssue] log.debug( f"Created root component of the BOM. " f"{root_component_name_str} ${root_component_version_str}" ) return root_component_object, main_supplier, project_spdx_id # Handles project build tools and adds them as components to the BOM metadata tools section. # This is for tools like CMake, Ninja, etc. # Unclear yet if compilers and linkers should also go here. def handle_project_build_tools( cydx_bom: Bom, project_build_tools_toml: list[BuildToolDict] ) -> None: log.debug("Processing project build tools.") for build_tool_toml in project_build_tools_toml: component_args = {} assign_optional_field(build_tool_toml, component_args, source_field="name") assign_optional_field( build_tool_toml, component_args, source_field="component_type", target_field="type", transform=get_component_type_for_str, ) assign_optional_field(build_tool_toml, component_args, "version") assign_optional_field(build_tool_toml, component_args, "description") component = Component(**component_args) log.debug( f"Created build tool component '{component.name} (version {component.version})'." # pyright: ignore [reportAttributeAccessIssue] ) cydx_bom.metadata.tools.components.add(component) # pyright: ignore [reportAttributeAccessIssue] # Creates a CycloneDX component from the toml component data and adds it to the BOM. # The SPDX equivalent would be a SPDX package backed by a CMake target. # We mostly reuse spdx ids as bom-refs, rather than generating new ones. Makes it # easier to cross-check with the original SPDX data. And it's not like CycloneDX forbids it. def process_component( cydx_bom: Bom, component_toml: ComponentDict, licenses_dict: LicensesDict, components: dict[str, Component], external_components_spdx_ids: set[str], main_supplier: Optional[OrganizationalEntity], license_factory: LicenseFactory, ) -> None: component_args = {} properties = [] error_context = "component" component_name = validate_required_field(component_toml, "name", error_context) component_args["name"] = component_name assign_optional_field( component_toml, component_args, "version", transform=lambda v: v if v != "unknown" else None, ) # A spdx id maps to a bom-ref in CycloneDX. spdx_id = validate_required_field(component_toml, "spdx_id", error_context) component_args["bom_ref"] = spdx_id # Some components might be external to the current BOM. # For example for the QtSvg module, QtCore is external, because it's built in qtbase, # not qtsvg. To be able to declare a dependency on QtCore, we still need to add it as a # component. But we add a BOM external reference link to it, signalling that the full # component information is in a different BOM. assign_optional_field( component_toml, component_args, source_field="external_bom_link", target_field="external_references", transform=lambda link: [ ExternalReference( type=ExternalReferenceType.BOM, # pyright: ignore [reportCallIssue] url=XsUri(link), # pyright: ignore [reportCallIssue] ) ], ) if component_toml.get("external_bom_link"): external_components_spdx_ids.add(spdx_id) assign_optional_field( component_toml, component_args, source_field="component_type", target_field="type", transform=get_component_type_for_str, ) assign_optional_field( component_toml, component_args, source_field="copyright", target_field="copyright", ) handle_component_licenses( component_toml, licenses_dict, license_factory, component_args ) purl_list = component_toml.get("purl_list", []) cpe_list = component_toml.get("cpe_list", []) cpe_purl_args, extra_cpe_purl_properties = get_purl_and_cpe_component_args( purl_list, cpe_list ) if extra_cpe_purl_properties: properties.extend(extra_cpe_purl_properties) if supplier_name := component_toml.get("supplier"): # If the specified supplier is the same as the main supplier, reuse it, because it has a # url. if main_supplier and supplier_name == main_supplier.name: # pyright: ignore [reportAttributeAccessIssue] component_args["supplier"] = main_supplier else: component_supplier = create_organization_entity(supplier_name) component_args["supplier"] = component_supplier # Properties are extra vendor-specific information that are not part of the official spec. if properties_list := component_toml.get("properties"): for prop in properties_list: properties.append( Property( name=prop["name"], # pyright: ignore [reportCallIssue] value=prop["value"], # pyright: ignore [reportCallIssue] ) ) if properties: component_args["properties"] = properties component = Component(**component_args, **cpe_purl_args) log.debug(f"Created component '{component.name} (version {component.version})'.") # pyright: ignore [reportAttributeAccessIssue] cydx_bom.components.add(component) # pyright: ignore [reportAttributeAccessIssue] components[spdx_id] = component # Creates dependencies between components in the BOM, as well as between the root component # and non-external components that are part of the current BOM. def handle_component_dependencies( cydx_bom: Bom, component_toml: ComponentDict, components: dict[str, Component], external_components_spdx_ids: set[str], root_component_object: Component, ) -> None: component_id = component_toml["spdx_id"] component_object = components.get(component_id) # If the component is not external, register it as a dependency of the root component, # aka the root component "contains" the non-external component. if component_id not in external_components_spdx_ids: cydx_bom.register_dependency(root_component_object, [component_object]) # pyright: ignore [reportAttributeAccessIssue, reportArgumentType] # Register relationships declared in the toml. if relationships := component_toml.get("relationships"): process_relationships(relationships, cydx_bom, components) def handle_project_relationships( cydx_bom: Bom, toml_data: TomlDataDict, components: dict[str, Component], ) -> None: root_component_toml = validate_required_field(toml_data, "root_component", "TOML data") if relationships := root_component_toml.get("relationships"): process_relationships(relationships, cydx_bom, components) # Creates dependencies between components based on the relationships declared in the toml data. # CycloneDX does not support different relationship types, like SPDX does, so the relationship # type is ignored. def process_relationships(relationships: list[RelationshipDict], cydx_bom: Bom, components: dict[str, Component]) -> None: for relationship in relationships: from_spdx_id = relationship["relationship_from"] to_spdx_id = relationship["relationship_to"] relationship_type = relationship["relationship_type"] if from_spdx_id not in components: log.warning( f"Component '{from_spdx_id}' in 'from' dependency is unknown, skipping." ) continue if to_spdx_id not in components: log.warning( f"Component {to_spdx_id} in 'from' dependency is unknown, skipping." ) continue from_component = components[from_spdx_id] to_component = components[to_spdx_id] cydx_bom.register_dependency(from_component, [to_component]) # pyright: ignore [reportAttributeAccessIssue, reportArgumentType] log.debug(f"Created dependency from '{from_spdx_id}' to '{to_spdx_id}'. The relationship type '{relationship_type}' is currently discarded.") # Handles PURL and CPE entries for a component. # The spec says that a component can have exactly one PURL. Qt generates several, because it's # unclear what will end up being useful for tooling. The other PURLs are added as extra # component properties. Same for CPEs. # The field names were picked to match the names generated by the python spdx to cyclonedx converter. def get_purl_and_cpe_component_args( purl_list: list[str], cpe_list: list[str], ) -> tuple[dict[str, Any], list[Property]]: component_args = {} properties = [] purl_objects = [PackageURL.from_string(purl) for purl in purl_list if purl] if len(purl_objects) > 0: # First PURL will be the main one. purl = purl_objects.pop(0) component_args["purl"] = purl # Rest will be properties. for purl_entry in purl_objects: properties.append( Property( name="spdx:external-reference:package-manager:purl", # pyright: ignore [reportCallIssue] value=str(purl_entry), # pyright: ignore [reportCallIssue] ) ) if len(cpe_list) > 0: # First CPE will be the main one. cpe = cpe_list.pop(0) component_args["cpe"] = cpe # Rest will be properties. for cpe_entry in cpe_list: properties.append( Property( name="spdx:external-reference:security:cpe23", # pyright: ignore [reportCallIssue] value=str(cpe_entry), # pyright: ignore [reportCallIssue] ) ) return component_args, properties # Handles licenses for a component. def handle_component_licenses( component_toml: ComponentDict, licenses_dict: LicensesDict, license_factory: LicenseFactory, component_args: dict[str, Any], ) -> None: def transformer(license_expression: str) -> Optional[list[LicenseExpression]]: acknowledgement = LicenseAcknowledgement.CONCLUDED return convert_license_expression_license_objects( license_expression, licenses_dict, license_factory, acknowledgement ) assign_optional_field( component_toml, component_args, source_field="license_concluded_expression", target_field="licenses", transform=transformer, ) # Transforms a spdx license expression into CycloneDX license objects. def convert_license_expression_license_objects( license_expression: str, licenses_dict: LicensesDict, license_factory: LicenseFactory, acknowledgement: LicenseAcknowledgement, ) -> Optional[list[LicenseExpression]]: if "LicenseRef-" in license_expression: return handle_custom_license_ref_expression( license_expression, licenses_dict, license_factory, acknowledgement ) else: return handle_standard_license_ref_expression( license_expression, license_factory, acknowledgement ) # CycloneDX v1.6 doesn't support an expression that contains LicenseRef- in it. # CycloneDX v1.7 seemingly does. # https://github.com/CycloneDX/specification/issues/454 # https://github.com/CycloneDX/specification/issues/554 # https://github.com/CycloneDX/specification/pull/582 # # Split LicenseRef-* expressions into separate licenses, by which we lose the expression structure, # but at least preserve the license information. # # TODO: Improve this when CycloneDX v1.7 generation is added. def handle_custom_license_ref_expression( license_expression: str, licenses_dict: LicensesDict, license_factory: LicenseFactory, acknowledgement: LicenseAcknowledgement, ) -> list[LicenseExpression]: license_objects = [] licensing = get_spdx_licensing() symbols = licensing.license_symbols(license_expression) for symbol in symbols: license_text = None # This can be either a known public spdx id, in which case we won't have text for. # Or it can be a LicenseRef-* for which we have license text in the licenses_dict. maybe_license_id = symbol.key # pyright: ignore [reportAttributeAccessIssue] if maybe_license_id in licenses_dict: # Get the text for the LicenseRef-* entry. maybe_license_text = licenses_dict[maybe_license_id].get("text") license_text = AttachedText(content=maybe_license_text) # pyright: ignore [reportCallIssue] license_obj = license_factory.make_from_string( maybe_license_id, license_text=license_text, license_acknowledgement=acknowledgement, ) license_objects.append(license_obj) return license_objects # Transforms a spdx license expression without LicenseRef-* into a CycloneDX license object. def handle_standard_license_ref_expression( license_expression: str, license_factory: LicenseFactory, acknowledgement: LicenseAcknowledgement, ) -> list[LicenseExpression]: license_obj = license_factory.make_with_expression( expression=license_expression, acknowledgement=acknowledgement ) return [license_obj] # Transform from enum string value to actual enum. def get_component_type_for_str(component_type_str: str) -> ComponentType: return ComponentType(component_type_str) # Creates an OrganizationalEntity for a supplier/manufacturer. def create_organization_entity( supplier_name: Optional[str] = None, supplier_url: Optional[str] = None ) -> Optional[OrganizationalEntity]: args = {} if supplier_name: args["name"] = supplier_name if supplier_url: args["urls"] = [XsUri(supplier_url)] # pyright: ignore [reportCallIssue] # Check if args has at least one entry. if len(args) > 0: return OrganizationalEntity(**args) return None # Creates an OrganizationalContact for a supplier/manufacturer. def create_organization_contact( contact_name: Optional[str] = None, contact_email: Optional[str] = None ) -> Optional[OrganizationalContact]: args = {} if contact_name: args["name"] = contact_name if contact_email: args["email"] = contact_email # pyright: ignore [reportCallIssue] # Check if args has at least one entry. if len(args) > 0: return OrganizationalContact(**args) return None if __name__ == "__main__": main()