text: """
  Pydantic models for validating cumulusci.yml
  
  Note: If you change the model here, you should run `make schema`
  to update the JSON Schema version in cumulusci.jsonschema.json
  """
  
  import enum
  from logging import getLogger
  from pathlib import Path
  from typing import Any, Dict, List, Optional, Sequence, Union
  
  from pydantic import Field, root_validator
  from pydantic.types import DirectoryPath
  from typing_extensions import Literal, TypedDict
  
  from cumulusci.utils.fileutils import DataInput, load_from_source
  from cumulusci.utils.yaml.model_parser import CCIDictModel, HashableBaseModel
  from cumulusci.utils.yaml.safer_loader import load_yaml_data
  
  default_logger = getLogger(__name__)
  
  
  #  type aliases
  PythonClassPath = str
  URL = str
  
  
  # additionalProperties here works around an
  # incompatibility with VSCode's Red Hat YAML validator
  # Can probably remove it at some future date if the
  # bug/incompatibilty is fixed elsewhere in the stack
  VSCodeFriendlyDict = Field({}, additionalProperties=True)
  
  
  class PreflightCheck(CCIDictModel):
      when: str = None
      action: str = None
      message: str = None
  
  
  class Step(CCIDictModel):
      task: str = None
      flow: str = None
      ignore_failure: bool = False
      when: str = None  # is this allowed?
      options: Dict[str, Any] = VSCodeFriendlyDict
      ui_options: Dict[str, Any] = VSCodeFriendlyDict
      checks: List[PreflightCheck] = []
      description: str = None
  
      @root_validator()
      def _check(cls, values):
          has_task = values.get("task") and values["task"] != "None"
          has_flow = values.get("flow") and values["flow"] != "None"
          assert not (
              has_task and has_flow
          ), "Steps must have either task or flow but not both"
          return values
  
  
  class Task(CCIDictModel):
      class_path: str = None
      description: str = None
      group: str = None
      # additionalProperties here works around an
      # incompatibility with VSCode's Red Hat YAML validator
      options: Dict[str, Any] = VSCodeFriendlyDict
      ui_options: Dict[str, Any] = VSCodeFriendlyDict
      name: str = None  # get rid of this???
  
  
  class Flow(CCIDictModel):
      description: str = None
      steps: Dict[str, Step] = None
      group: str = None
  
  
  class Package(CCIDictModel):
      name: Optional[str] = None
      name_managed: Optional[str] = None
      namespace: Optional[str] = None
      install_class: str = None
      uninstall_class: str = None
      api_version: str = None
      metadata_package_id: str = None
  
  
  class Test(CCIDictModel):
      name_match: str
  
  
  class ReleaseNotesParser(CCIDictModel):
      class_path: PythonClassPath
      title: str
  
  
  class ReleaseNotes(CCIDictModel):
      parsers: Dict[int, ReleaseNotesParser]
  
  
  class Git(CCIDictModel):
      repo_url: str = None
      default_branch: str = None
      prefix_feature: str = None
      prefix_beta: str = None
      prefix_release: str = None
      push_prefix_sandbox: str = None
      push_prefix_production: str = None
      release_notes: ReleaseNotes = None
      two_gp_context: str = Field(None, alias="2gp_context")
  
  
  class Plan(CCIDictModel):  # MetaDeploy plans
      title: str = None
      description: str = None
      tier: Literal["primary", "secondary", "additional"] = None
      slug: str = None
      is_listed: bool = True
      steps: Dict[str, Step] = None
      checks: List[PreflightCheck] = []
      error_message: str = None
      post_install_message: str = None
      preflight_message: str = None
  
  
  class DependencyResolutions(CCIDictModel):
      production: str = None
      preproduction: str = None
      resolution_strategies: Dict[str, List[str]] = None
  
  
  class Project(CCIDictModel):
      name: str = None
      package: Package = None
      test: Test = None
      git: Git = None
      dependencies: List[Dict[str, str]] = None  # TODO
      dependency_resolutions: DependencyResolutions = None
      source_format: Literal["sfdx", "mdapi"] = "mdapi"
  
  
  class ScratchOrg(CCIDictModel):
      config_file: Path = None
      days: int = None
      namespaced: str = None
      setup_flow: str = None
      noancestors: bool = None
  
  
  class Orgs(CCIDictModel):
      scratch: Dict[str, ScratchOrg] = None
  
  
  class ServiceAttribute(CCIDictModel):
      description: str = None
      required: bool = None
      default_factory: PythonClassPath = None
      default: str = None
  
  
  class Service(CCIDictModel):
      description: str = None
      class_path: Optional[str]
      attributes: Dict[str, ServiceAttribute] = None
      validator: PythonClassPath = None
  
  
  class CumulusCIConfig(CCIDictModel):
      keychain: PythonClassPath
  
  
  class GitHubSourceRelease(str, enum.Enum):
      LATEST = "latest"
      PREVIOUS = "previous"
      LATEST_BETA = "latest_beta"
  
  
  class GitHubSourceModel(HashableBaseModel):
      github: str
      resolution_strategy: Optional[str]
      commit: Optional[str]
      ref: Optional[str]
      branch: Optional[str]
      tag: Optional[str]
      release: Optional[GitHubSourceRelease]
      description: Optional[str]
  
      @root_validator
      def validate(cls, values):
          exclusive_keys = [
              "resolution_strategy",
              "commit",
              "ref",
              "branch",
              "tag",
              "release",
          ]
          key_count = len([x for x in exclusive_keys if x in values and values[x]])
          if key_count > 1:
              raise ValueError(
                  'Sources must use only one of "resolution_strategy", "commit", "ref", "branch", "tag", or "release".'
              )
          elif key_count == 0:
              values["resolution_strategy"] = "production"
  
          return values
  
  
  class LocalFolderSourceModel(HashableBaseModel):
      path: DirectoryPath
  
  
  class CumulusCLIConfig(CCIDictModel):
      show_stacktraces: bool = False
      plain_output: bool = None
  
  
  class CumulusCIRoot(CCIDictModel):
      tasks: Dict[str, Task] = {}
      flows: Dict[str, Flow] = {}
      project: Project = {}
      orgs: Orgs = {}
      services: Dict[str, Service] = {}
      cumulusci: CumulusCIConfig = None
      plans: Dict[str, Plan] = {}
      minimum_cumulusci_version: str = None
      sources: Dict[str, Union[LocalFolderSourceModel, GitHubSourceModel]] = {}
      cli: CumulusCLIConfig = None
  
  
  class CumulusCIFile(CCIDictModel):
      __root__: Union[CumulusCIRoot, None]
  
  
  def parse_from_yaml(source) -> dict:
      "Parse from a path, url, path-like or file-like"
      return CumulusCIFile.parse_from_yaml(source) or {}
  
  
  def validate_data(
      data: Union[dict, list],
      context: str = None,
      on_error: callable = None,
  ):
      """Validate data which has already been loaded into a dictionary or list.
  
      context is a string that will be used to give context to error messages.
      on_error will be called for any validation errors with a dictionary in Pydantic error format
  
      https://pydantic-docs.helpmanual.io/usage/models/#error-handling
      """
      return CumulusCIFile.validate_data(data, context=context, on_error=on_error)
  
  
  class ErrorDict(TypedDict):
      "The structure of a Pydantic error dictionary. Google TypedDict if its new to you."
      loc: Sequence[Union[str, int]]
      msg: str
      type: str
  
  
  has_shown_yaml_error_message = False
  
  
  def _log_yaml_errors(logger, errors: List[ErrorDict]):
      "Format and log a Pydantic-style error dictionary"
      global has_shown_yaml_error_message
      plural = "" if len(errors) <= 1 else "s"
      logger.warning(f"CumulusCI Configuration Warning{plural}:")
      for error in errors:
          loc = " -> ".join(repr(x) for x in error["loc"] if x != "__root__")
          logger.warning("  %s\n    %s", loc, error["msg"])
      if not has_shown_yaml_error_message:
          logger.error(
              "NOTE: These warnings may become errors in future versions of CumulusCI."
          )
          logger.error(
              "If you think your YAML has no error, please report the bug to the CumulusCI team."
          )
          logger.error("https://github.com/SFDO-Tooling/CumulusCI/issues/\n")
          has_shown_yaml_error_message = True
  
  
  def cci_safe_load(
      source: DataInput, context: str = None, on_error: callable = None, logger=None
  ) -> dict:
      """Load a CumulusCI.yml file and issue warnings for unknown structures."""
      errors = []
      assert not (
          on_error and logger
      ), "Please specify either on_error or logger but not both"
      on_error = on_error or errors.append
  
      logger = logger or default_logger
  
      with load_from_source(source) as (data_stream, filename):
          data = load_yaml_data(data_stream, filename)
          context = context or filename
  
          try:
              validate_data(data, context=context, on_error=on_error)
              if errors:
                  _log_yaml_errors(logger, errors)
          except Exception as e:
              # should never be executed
              print(f"Error validating cumulusci.yml {e}")
              if on_error:
                  on_error(
                      {
                          "loc": (context,),
                          "msg": f"Error validating cumulusci.yml {e}",
                          "type": "exception",
                      }
                  )
              pass
          return data or {}
  
  
  def _validate_files(globs):
      "Validate YML files from Dev CLI for smoke testing"
  
      from glob import glob
  
      errors = []
      for g in globs:
          print(g)
          filenames = glob(g, recursive=True)
          for filename in filenames:
              print("Validating", filename)
  
              cci_safe_load(filename, filename, on_error=errors.append)
      return errors
  
  
  def _validate_url(url):  # pragma: no cover
      "Validate YML URL from Dev CLI for smoke testing"
      errors = []
      cci_safe_load(url, url, on_error=errors.append)
      return errors
  
  
  # validate YML files as a CLI for smoke testing
  if __name__ == "__main__":  # pragma: no cover
      import sys
      from pprint import pprint
  
      if sys.argv[1].startswith("http"):
          pprint(_validate_url(sys.argv[1]))
      else:
          pprint(_validate_files(sys.argv[1:]))
  