Repository: packit-service/packit
Pull request #858 (2020-06-08 17:18:12)

enable overriding package config per job
* [x] initial implementation, new tests
* [x] fix tests
* [x] get @csomh's blessing
* [x] get @lachmanfrantisek's blessing

```
Add all package config's attributes to jobconfig (except for jobs)

This will enable people to override things per job and do really crazy
stuff, most notably matrices - set up different configurations for
builds, releases, etc.
```
Related packit-service/packit-service#418
Old implementation: #848

bonus:
```
PackageCfg: stop eating those kwargs!

Why were we actually doing this? We could eat a misspelled argument like
this and produce an incorrect result, without any exceptions.

With this change, when something wrong gets passed to PackageConfig, we
will literally see what and how so we can fix the original problem.
```


Patch:
diff --git a/packit/config/common_package_config.py b/packit/config/common_package_config.py
new file mode 100644
index 00000000..538fe94a
--- /dev/null
+++ b/packit/config/common_package_config.py
@@ -0,0 +1,141 @@
+# MIT License
+#
+# Copyright (c) 2018-2019 Red Hat, Inc.
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""
+Common package config attributes so they can be imported both in PackageConfig and JobConfig
+"""
+from typing import Optional, List, Union, Dict
+
+from packit.actions import ActionName
+from packit.config.sync_files_config import SyncFilesConfig
+from packit.config.notifications import (
+    NotificationsConfig,
+    PullRequestNotificationsConfig,
+)
+from packit.constants import PROD_DISTGIT_URL
+
+
+class CommonPackageConfig:
+    """
+    We want JobConfig to hold all the attributes from PackageConfig so we don't need to
+    pass both PackageConfig and JobConfig to handlers in p-s. We also want people
+    to be able to override global PackageConfig attributes per job.
+
+                        CommonPackageConfig
+                              /      \
+                   PackageConfig   JobConfig
+                          //
+              contains [JobConfig]
+    """
+
+    def __init__(
+        self,
+        config_file_path: Optional[str] = None,
+        specfile_path: Optional[str] = None,
+        synced_files: Optional[SyncFilesConfig] = None,
+        dist_git_namespace: str = None,
+        upstream_project_url: str = None,  # can be URL or path
+        upstream_package_name: str = None,
+        downstream_project_url: str = None,
+        downstream_package_name: str = None,
+        dist_git_base_url: str = None,
+        create_tarball_command: List[str] = None,
+        current_version_command: List[str] = None,
+        actions: Dict[ActionName, Union[str, List[str]]] = None,
+        upstream_ref: Optional[str] = None,
+        allowed_gpg_keys: Optional[List[str]] = None,
+        create_pr: bool = True,
+        spec_source_id: str = "Source0",
+        upstream_tag_template: str = "{version}",
+        patch_generation_ignore_paths: List[str] = None,
+        notifications: Optional[NotificationsConfig] = None,
+    ):
+        self.config_file_path: Optional[str] = config_file_path
+        self.specfile_path: Optional[str] = specfile_path
+        self.synced_files: SyncFilesConfig = synced_files or SyncFilesConfig([])
+        self.patch_generation_ignore_paths = patch_generation_ignore_paths or []
+        self.dist_git_namespace: str = dist_git_namespace or "rpms"
+        self.upstream_project_url: Optional[str] = upstream_project_url
+        self.upstream_package_name: Optional[str] = upstream_package_name
+        # this is generated by us
+        self.downstream_package_name: Optional[str] = downstream_package_name
+        self.dist_git_base_url: str = dist_git_base_url or PROD_DISTGIT_URL
+        self._downstream_project_url: str = downstream_project_url
+        # path to a local git clone of the dist-git repo; None means to clone in a tmpdir
+        self.dist_git_clone_path: Optional[str] = None
+        self.actions = actions or {}
+        self.upstream_ref: Optional[str] = upstream_ref
+        self.allowed_gpg_keys = allowed_gpg_keys
+        self.create_pr: bool = create_pr
+        self.spec_source_id: str = spec_source_id
+        self.notifications = notifications or NotificationsConfig(
+            pull_request=PullRequestNotificationsConfig()
+        )
+
+        # command to generate a tarball from the upstream repo
+        # uncommitted changes will not be present in the archive
+        self.create_tarball_command: List[str] = create_tarball_command
+        # command to get current version of the project
+        self.current_version_command: List[str] = current_version_command or [
+            "git",
+            "describe",
+            "--abbrev=0",
+            "--tags",
+            "--match",
+            "*",
+        ]
+        # template to create an upstream tag name (upstream may use different tagging scheme)
+        self.upstream_tag_template = upstream_tag_template
+
+    def __repr__(self):
+        return (
+            "CommonPackageConfig("
+            f"specfile_path='{self.specfile_path}', "
+            f"synced_files='{self.synced_files}', "
+            f"dist_git_namespace='{self.dist_git_namespace}', "
+            f"upstream_project_url='{self.upstream_project_url}', "
+            f"upstream_package_name='{self.upstream_package_name}', "
+            f"downstream_project_url='{self.downstream_project_url}', "
+            f"downstream_package_name='{self.downstream_package_name}', "
+            f"dist_git_base_url='{self.dist_git_base_url}', "
+            f"create_tarball_command='{self.create_tarball_command}', "
+            f"current_version_command='{self.current_version_command}', "
+            f"actions='{self.actions}', "
+            f"upstream_ref='{self.upstream_ref}', "
+            f"allowed_gpg_keys='{self.allowed_gpg_keys}', "
+            f"create_pr='{self.create_pr}', "
+            f"spec_source_id='{self.spec_source_id}', "
+            f"upstream_tag_template='{self.upstream_tag_template}', "
+            f"patch_generation_ignore_paths='{self.patch_generation_ignore_paths}')"
+        )
+
+    @property
+    def downstream_project_url(self) -> str:
+        if not self._downstream_project_url:
+            self._downstream_project_url = self.dist_git_package_url
+        return self._downstream_project_url
+
+    @property
+    def dist_git_package_url(self):
+        return (
+            f"{self.dist_git_base_url}{self.dist_git_namespace}/"
+            f"{self.downstream_package_name}.git"
+        )
diff --git a/packit/config/job_config.py b/packit/config/job_config.py
index e4f07ad4..d41effc2 100644
--- a/packit/config/job_config.py
+++ b/packit/config/job_config.py
@@ -19,12 +19,15 @@
 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
-
-
+from copy import deepcopy
 from enum import Enum
 from logging import getLogger
-from typing import List, Set
+from typing import List, Set, Dict, Optional, Union
 
+from packit.actions import ActionName
+from packit.config.common_package_config import CommonPackageConfig
+from packit.config.notifications import NotificationsConfig
+from packit.config.sync_files_config import SyncFilesConfig
 from packit.exceptions import PackitConfigException
 
 logger = getLogger(__name__)
@@ -111,20 +114,85 @@ class JobMetadataConfig:
         )
 
 
-class JobConfig:
+class JobConfig(CommonPackageConfig):
+    """
+    Definition of a job.
+
+    We want users to be able to override global attributes per job hence
+    this inherits from CommonPackageConfig which contains all the definitions.
+    """
+
     def __init__(
         self,
         type: JobType,
         trigger: JobConfigTriggerType,
         metadata: JobMetadataConfig = None,
+        config_file_path: Optional[str] = None,
+        specfile_path: Optional[str] = None,
+        synced_files: Optional[SyncFilesConfig] = None,
+        dist_git_namespace: str = None,
+        upstream_project_url: str = None,  # can be URL or path
+        upstream_package_name: str = None,
+        downstream_project_url: str = None,
+        downstream_package_name: str = None,
+        dist_git_base_url: str = None,
+        create_tarball_command: List[str] = None,
+        current_version_command: List[str] = None,
+        actions: Dict[ActionName, Union[str, List[str]]] = None,
+        upstream_ref: Optional[str] = None,
+        allowed_gpg_keys: Optional[List[str]] = None,
+        create_pr: bool = True,
+        spec_source_id: str = "Source0",
+        upstream_tag_template: str = "{version}",
+        patch_generation_ignore_paths: List[str] = None,
+        notifications: Optional[NotificationsConfig] = None,
     ):
+        super().__init__(
+            config_file_path=config_file_path,
+            specfile_path=specfile_path,
+            synced_files=synced_files,
+            dist_git_namespace=dist_git_namespace,
+            upstream_project_url=upstream_project_url,
+            upstream_package_name=upstream_package_name,
+            downstream_project_url=downstream_project_url,
+            downstream_package_name=downstream_package_name,
+            dist_git_base_url=dist_git_base_url,
+            create_tarball_command=create_tarball_command,
+            current_version_command=current_version_command,
+            actions=actions,
+            upstream_ref=upstream_ref,
+            allowed_gpg_keys=allowed_gpg_keys,
+            create_pr=create_pr,
+            spec_source_id=spec_source_id,
+            upstream_tag_template=upstream_tag_template,
+            patch_generation_ignore_paths=patch_generation_ignore_paths,
+            notifications=notifications,
+        )
         self.type: JobType = type
         self.trigger: JobConfigTriggerType = trigger
         self.metadata: JobMetadataConfig = metadata or JobMetadataConfig()
 
     def __repr__(self):
         return (
-            f"JobConfig(job={self.type}, trigger={self.trigger}, meta={self.metadata})"
+            f"JobConfig(job={self.type}, trigger={self.trigger}, meta={self.metadata}, "
+            f"config_file_path='{self.config_file_path}', "
+            f"specfile_path='{self.specfile_path}', "
+            f"synced_files='{self.synced_files}', "
+            f"dist_git_namespace='{self.dist_git_namespace}', "
+            f"upstream_project_url='{self.upstream_project_url}', "
+            f"upstream_package_name='{self.upstream_package_name}', "
+            f"downstream_project_url='{self.downstream_project_url}', "
+            f"downstream_package_name='{self.downstream_package_name}', "
+            f"dist_git_base_url='{self.dist_git_base_url}', "
+            f"create_tarball_command='{self.create_tarball_command}', "
+            f"current_version_command='{self.current_version_command}', "
+            f"actions='{self.actions}', "
+            f"upstream_ref='{self.upstream_ref}', "
+            f"allowed_gpg_keys='{self.allowed_gpg_keys}', "
+            f"create_pr='{self.create_pr}', "
+            f"spec_source_id='{self.spec_source_id}', "
+            f"upstream_tag_template='{self.upstream_tag_template}', "
+            f"patch_generation_ignore_paths='{self.patch_generation_ignore_paths}')"
         )
 
     @classmethod
@@ -144,18 +212,41 @@ class JobConfig:
             self.type == other.type
             and self.trigger == other.trigger
             and self.metadata == other.metadata
+            and self.specfile_path == other.specfile_path
+            and self.synced_files == other.synced_files
+            and self.dist_git_namespace == other.dist_git_namespace
+            and self.upstream_project_url == other.upstream_project_url
+            and self.upstream_package_name == other.upstream_package_name
+            and self.downstream_project_url == other.downstream_project_url
+            and self.downstream_package_name == other.downstream_package_name
+            and self.dist_git_base_url == other.dist_git_base_url
+            and self.current_version_command == other.current_version_command
+            and self.create_tarball_command == other.create_tarball_command
+            and self.actions == other.actions
+            and self.allowed_gpg_keys == other.allowed_gpg_keys
+            and self.create_pr == other.create_pr
+            and self.spec_source_id == other.spec_source_id
+            and self.upstream_tag_template == other.upstream_tag_template
         )
 
 
-default_jobs = [
-    JobConfig(
-        type=JobType.tests,
-        trigger=JobConfigTriggerType.pull_request,
-        metadata=JobMetadataConfig(targets=["fedora-stable"]),
-    ),
-    JobConfig(
-        type=JobType.propose_downstream,
-        trigger=JobConfigTriggerType.release,
-        metadata=JobMetadataConfig(dist_git_branches=["fedora-all"]),
-    ),
-]
+def get_default_jobs() -> List[Dict]:
+    """
+    this returns a list of dicts so it can be properly parsed and defaults would be set
+    """
+    # deepcopy = list and dict are mutable, we want to make sure
+    # no one will mutate the default jobs (hello tests)
+    return deepcopy(
+        [
+            {
+                "job": "tests",
+                "trigger": "pull_request",
+                "metadata": {"targets": "fedora-stable"},
+            },
+            {
+                "job": "propose_downstream",
+                "trigger": "release",
+                "metadata": {"dist_git_branches": "fedora-all"},
+            },
+        ]
+    )
diff --git a/packit/config/notifications.py b/packit/config/notifications.py
new file mode 100644
index 00000000..b4818575
--- /dev/null
+++ b/packit/config/notifications.py
@@ -0,0 +1,35 @@
+# MIT License
+#
+# Copyright (c) 2018-2019 Red Hat, Inc.
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+
+class PullRequestNotificationsConfig:
+    """ Configuration of commenting on pull requests. """
+
+    def __init__(self, successful_build: bool = True):
+        self.successful_build = successful_build
+
+
+class NotificationsConfig:
+    """ Configuration of notifications. """
+
+    def __init__(self, pull_request: PullRequestNotificationsConfig):
+        self.pull_request = pull_request
diff --git a/packit/config/package_config.py b/packit/config/package_config.py
index 5455db37..c0718429 100644
--- a/packit/config/package_config.py
+++ b/packit/config/package_config.py
@@ -29,29 +29,17 @@ from yaml import safe_load
 
 from ogr.abstract import GitProject
 from packit.actions import ActionName
-from packit.config.job_config import JobConfig, default_jobs, JobType
+from packit.config.common_package_config import CommonPackageConfig
+from packit.config.job_config import JobConfig, get_default_jobs, JobType
+from packit.config.notifications import NotificationsConfig
 from packit.config.sync_files_config import SyncFilesConfig, SyncFilesItem
-from packit.constants import CONFIG_FILE_NAMES, PROD_DISTGIT_URL
+from packit.constants import CONFIG_FILE_NAMES
 from packit.exceptions import PackitConfigException
 
 logger = logging.getLogger(__name__)
 
 
-class PullRequestNotificationsConfig:
-    """ Configuration of commenting on pull requests. """
-
-    def __init__(self, successful_build: bool = True):
-        self.successful_build = successful_build
-
-
-class NotificationsConfig:
-    """ Configuration of notifications. """
-
-    def __init__(self, pull_request: PullRequestNotificationsConfig):
-        self.pull_request = pull_request
-
-
-class PackageConfig:
+class PackageConfig(CommonPackageConfig):
     """
     Config class for upstream/downstream packages;
     this is the config people put in their repos
@@ -79,48 +67,29 @@ class PackageConfig:
         upstream_tag_template: str = "{version}",
         patch_generation_ignore_paths: List[str] = None,
         notifications: Optional[NotificationsConfig] = None,
-        **kwargs,
     ):
-        self.config_file_path: Optional[str] = config_file_path
-        self.specfile_path: Optional[str] = specfile_path
-        self.synced_files: SyncFilesConfig = synced_files or SyncFilesConfig([])
-        self.patch_generation_ignore_paths = patch_generation_ignore_paths or []
-        self.jobs: List[JobConfig] = jobs or []
-        self.dist_git_namespace: str = dist_git_namespace or "rpms"
-        self.upstream_project_url: Optional[str] = upstream_project_url
-        self.upstream_package_name: Optional[str] = upstream_package_name
-        # this is generated by us
-        self.downstream_package_name: Optional[str] = downstream_package_name
-        self.dist_git_base_url: str = dist_git_base_url or PROD_DISTGIT_URL
-        self._downstream_project_url: str = downstream_project_url
-        # path to a local git clone of the dist-git repo; None means to clone in a tmpdir
-        self.dist_git_clone_path: Optional[str] = None
-        self.actions = actions or {}
-        self.upstream_ref: Optional[str] = upstream_ref
-        self.allowed_gpg_keys = allowed_gpg_keys
-        self.create_pr: bool = create_pr
-        self.spec_source_id: str = spec_source_id
-        self.notifications = notifications or NotificationsConfig(
-            pull_request=PullRequestNotificationsConfig()
+        super().__init__(
+            config_file_path=config_file_path,
+            specfile_path=specfile_path,
+            synced_files=synced_files,
+            dist_git_namespace=dist_git_namespace,
+            upstream_project_url=upstream_project_url,
+            upstream_package_name=upstream_package_name,
+            downstream_project_url=downstream_project_url,
+            downstream_package_name=downstream_package_name,
+            dist_git_base_url=dist_git_base_url,
+            create_tarball_command=create_tarball_command,
+            current_version_command=current_version_command,
+            actions=actions,
+            upstream_ref=upstream_ref,
+            allowed_gpg_keys=allowed_gpg_keys,
+            create_pr=create_pr,
+            spec_source_id=spec_source_id,
+            upstream_tag_template=upstream_tag_template,
+            patch_generation_ignore_paths=patch_generation_ignore_paths,
+            notifications=notifications,
         )
-
-        # command to generate a tarball from the upstream repo
-        # uncommitted changes will not be present in the archive
-        self.create_tarball_command: List[str] = create_tarball_command
-        # command to get current version of the project
-        self.current_version_command: List[str] = current_version_command or [
-            "git",
-            "describe",
-            "--abbrev=0",
-            "--tags",
-            "--match",
-            "*",
-        ]
-        # template to create an upstream tag name (upstream may use different tagging scheme)
-        self.upstream_tag_template = upstream_tag_template
-
-        if kwargs:
-            logger.warning(f"Following kwargs were not processed:" f"{kwargs}")
+        self.jobs: List[JobConfig] = jobs or []
 
     def __repr__(self):
         return (
@@ -146,19 +115,6 @@ class PackageConfig:
             f"patch_generation_ignore_paths='{self.patch_generation_ignore_paths}')"
         )
 
-    @property
-    def downstream_project_url(self) -> str:
-        if not self._downstream_project_url:
-            self._downstream_project_url = self.dist_git_package_url
-        return self._downstream_project_url
-
-    @property
-    def dist_git_package_url(self):
-        return (
-            f"{self.dist_git_base_url}{self.dist_git_namespace}/"
-            f"{self.downstream_package_name}.git"
-        )
-
     @classmethod
     def get_from_dict(
         cls,
@@ -173,6 +129,10 @@ class PackageConfig:
         if config_file_path and not raw_dict.get("config_file_path", None):
             raw_dict.update(config_file_path=config_file_path)
 
+        if "jobs" not in raw_dict:
+            # we want default jobs to go through the proper parsing process
+            raw_dict["jobs"] = get_default_jobs()
+
         package_config = PackageConfigSchema().load_config(raw_dict)
 
         if not getattr(package_config, "specfile_path", None):
@@ -187,9 +147,6 @@ class PackageConfig:
         if not getattr(package_config, "downstream_package_name", None) and repo_name:
             package_config.downstream_package_name = repo_name
 
-        if "jobs" not in raw_dict:
-            package_config.jobs = default_jobs
-
         logger.debug(package_config)
         return package_config
 
diff --git a/packit/schema.py b/packit/schema.py
index db1dad74..fd4d6f58 100644
--- a/packit/schema.py
+++ b/packit/schema.py
@@ -20,7 +20,7 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 from logging import getLogger
-from typing import Dict, Any, Optional, Mapping
+from typing import Dict, Any, Optional, Mapping, Union
 
 from marshmallow import Schema, fields, post_load, pre_load, ValidationError, post_dump
 
@@ -37,14 +37,11 @@ from packit.config import PackageConfig, Config, SyncFilesConfig
 from packit.config.job_config import (
     JobType,
     JobConfig,
-    default_jobs,
     JobConfigTriggerType,
     JobMetadataConfig,
 )
-from packit.config.package_config import (
-    NotificationsConfig,
-    PullRequestNotificationsConfig,
-)
+from packit.config.package_config import NotificationsConfig
+from packit.config.notifications import PullRequestNotificationsConfig
 from packit.sync import SyncFilesItem
 
 logger = getLogger(__name__)
@@ -174,6 +171,26 @@ class MM23Schema(Schema):
         return {key: value for key, value in data.items() if value is not None}
 
 
+class PullRequestNotificationsSchema(MM23Schema):
+    """ Configuration of commenting on pull requests. """
+
+    successful_build = fields.Bool(default=True)
+
+    @post_load
+    def make_instance(self, data, **kwargs):
+        return PullRequestNotificationsConfig(**data)
+
+
+class NotificationsSchema(MM23Schema):
+    """ Configuration of notifications. """
+
+    pull_request = fields.Nested(PullRequestNotificationsSchema)
+
+    @post_load
+    def make_instance(self, data, **kwargs):
+        return NotificationsConfig(**data)
+
+
 class SyncFilesConfigSchema(MM23Schema):
     """
     Schema for processing SyncFilesConfig config data.
@@ -232,43 +249,9 @@ class JobMetadataSchema(MM23Schema):
         return JobMetadataConfig(**data)
 
 
-class JobConfigSchema(MM23Schema):
-    """
-    Schema for processing JobConfig config data.
-    """
-
-    job = EnumField(JobType, required=True, attribute="type")
-    trigger = EnumField(JobConfigTriggerType, required=True)
-    metadata = fields.Nested(JobMetadataSchema)
-
-    @post_load
-    def make_instance(self, data, **_):
-        return JobConfig(**data)
-
-
-class PullRequestNotificationsSchema(MM23Schema):
-    """ Configuration of commenting on pull requests. """
-
-    successful_build = fields.Bool(default=True)
-
-    @post_load
-    def make_instance(self, data, **kwargs):
-        return PullRequestNotificationsConfig(**data)
-
-
-class NotificationsSchema(MM23Schema):
-    """ Configuration of notifications. """
-
-    pull_request = fields.Nested(PullRequestNotificationsSchema)
-
-    @post_load
-    def make_instance(self, data, **kwargs):
-        return NotificationsConfig(**data)
-
-
-class PackageConfigSchema(MM23Schema):
+class CommonConfigSchema(MM23Schema):
     """
-    Schema for processing PackageConfig config data.
+    Common methods for JobConfigSchema and PackageConfigSchema
     """
 
     config_file_path = fields.String()
@@ -287,14 +270,63 @@ class PackageConfigSchema(MM23Schema):
     create_tarball_command = fields.List(fields.String())
     current_version_command = fields.List(fields.String())
     allowed_gpg_keys = fields.List(fields.String())
-    spec_source_id = fields.Method(deserialize="spec_source_id_fm")
+    spec_source_id = fields.Method(
+        deserialize="spec_source_id_fm", serialize="spec_source_id_serialize"
+    )
     synced_files = fields.Nested(SyncFilesConfigSchema)
-    jobs = fields.Nested(JobConfigSchema, many=True, default=default_jobs)
     actions = ActionField(default={})
     create_pr = fields.Bool(default=True)
     patch_generation_ignore_paths = fields.List(fields.String())
     notifications = fields.Nested(NotificationsSchema)
 
+    @staticmethod
+    def spec_source_id_serialize(value: PackageConfig):
+        return value.spec_source_id
+
+    @staticmethod
+    def spec_source_id_fm(value: Union[str, int]):
+        """
+        method used in spec_source_id field.Method
+        If value is int, it is transformed int -> "Source" + str(int)
+
+        ex.
+        1 -> "Source1"
+
+        :return str: prepends "Source" in case input value is int
+        """
+        if value:
+            try:
+                value = int(value)
+            except ValueError:
+                # not a number
+                pass
+            else:
+                # is a number!
+                value = f"Source{value}"
+        return value
+
+
+class JobConfigSchema(CommonConfigSchema):
+    """
+    Schema for processing JobConfig config data.
+    """
+
+    job = EnumField(JobType, required=True, attribute="type")
+    trigger = EnumField(JobConfigTriggerType, required=True)
+    metadata = fields.Nested(JobMetadataSchema)
+
+    @post_load
+    def make_instance(self, data, **_):
+        return JobConfig(**data)
+
+
+class PackageConfigSchema(CommonConfigSchema):
+    """
+    Schema for processing PackageConfig config data.
+    """
+
+    jobs = fields.Nested(JobConfigSchema, many=True)
+
     # list of deprecated keys and their replacement (new,old)
     deprecated_keys = (("upstream_package_name", "upstream_project_name"),)
 
@@ -302,6 +334,20 @@ class PackageConfigSchema(MM23Schema):
     def ordered_preprocess(self, data, **kwargs):
         data = self.rename_deprecated_keys(data, **kwargs)
         data = self.specfile_path_pre(data, **kwargs)
+        data = self.add_defaults_for_jobs(data, **kwargs)
+        return data
+
+    @staticmethod
+    def add_defaults_for_jobs(data, **_):
+        """
+        add all the fields (except for jobs) to every job so we can process only jobconfig in p-s
+        """
+        for job in data.get("jobs", []):
+            for k, v in data.items():
+                if k == "jobs":
+                    # overriding jobs doesn't make any sense
+                    continue
+                job.setdefault(k, v)
         return data
 
     def rename_deprecated_keys(self, data, **kwargs):
@@ -354,27 +400,6 @@ class PackageConfigSchema(MM23Schema):
     def make_instance(self, data, **kwargs):
         return PackageConfig(**data)
 
-    def spec_source_id_fm(self, value):
-        """
-        method used in spec_source_id field.Method
-        If value is int, it is transformed int -> "Source" + str(int)
-
-        ex.
-        1 -> "Source1"
-
-        :return str: prepends "Source" in case input value is int
-        """
-        if value:
-            try:
-                value = int(value)
-            except ValueError:
-                # not a number
-                pass
-            else:
-                # is a number!
-                value = f"Source{value}"
-        return value
-
 
 class UserConfigSchema(MM23Schema):
     """


Test patch:
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 349769d3..99b9a2f0 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -42,8 +42,9 @@ def get_job_config_dict_simple():
     return {"job": "build", "trigger": "release"}
 
 
-def get_job_config_simple():
-    return JobConfig(type=JobType.build, trigger=JobConfigTriggerType.release,)
+def get_job_config_simple(**kwargs):
+    """ pass kwargs to JobConfig constructor """
+    return JobConfig(type=JobType.build, trigger=JobConfigTriggerType.release, **kwargs)
 
 
 @pytest.fixture()
@@ -59,11 +60,13 @@ def get_job_config_dict_full():
     }
 
 
-def get_job_config_full():
+def get_job_config_full(**kwargs):
+    """ pass kwargs to JobConfig constructor """
     return JobConfig(
         type=JobType.propose_downstream,
         trigger=JobConfigTriggerType.pull_request,
         metadata=JobMetadataConfig(dist_git_branches=["master"]),
+        **kwargs
     )
 
 
@@ -80,25 +83,30 @@ def get_job_config_dict_build_for_branch():
     }
 
 
-def get_job_config_build_for_branch():
+def get_job_config_build_for_branch(**kwargs):
+    """ pass kwargs to JobConfig constructor """
     return JobConfig(
         type=JobType.copr_build,
         trigger=JobConfigTriggerType.commit,
         metadata=JobMetadataConfig(branch="build-branch", scratch=True),
+        **kwargs
     )
 
 
-def get_default_job_config():
+def get_default_job_config(**kwargs):
+    """ pass kwargs to JobConfig constructor """
     return [
         JobConfig(
             type=JobType.tests,
             trigger=JobConfigTriggerType.pull_request,
             metadata=JobMetadataConfig(targets=["fedora-stable"]),
+            **kwargs
         ),
         JobConfig(
             type=JobType.propose_downstream,
             trigger=JobConfigTriggerType.release,
             metadata=JobMetadataConfig(dist_git_branches=["fedora-all"]),
+            **kwargs
         ),
     ]
 
diff --git a/tests/unit/test_package_config.py b/tests/unit/test_package_config.py
index 6e324012..9de55bf2 100644
--- a/tests/unit/test_package_config.py
+++ b/tests/unit/test_package_config.py
@@ -407,6 +407,8 @@ def test_package_config_parse_error(raw):
             },
             PackageConfig(
                 specfile_path="fedora/package.spec",
+                downstream_package_name="package",
+                create_pr=False,
                 synced_files=SyncFilesConfig(
                     files_to_sync=[
                         SyncFilesItem(
@@ -414,9 +416,21 @@ def test_package_config_parse_error(raw):
                         )
                     ]
                 ),
-                jobs=[get_job_config_full()],
-                downstream_package_name="package",
-                create_pr=False,
+                jobs=[
+                    get_job_config_full(
+                        downstream_package_name="package",
+                        specfile_path="fedora/package.spec",
+                        create_pr=False,
+                        synced_files=SyncFilesConfig(
+                            files_to_sync=[
+                                SyncFilesItem(
+                                    src="fedora/package.spec",
+                                    dest="fedora/package.spec",
+                                )
+                            ]
+                        ),
+                    )
+                ],
             ),
             id="specfile_path+synced_files+job_config_full+downstream_package_name+create_pr",
         ),
@@ -433,6 +447,7 @@ def test_package_config_parse_error(raw):
                 "downstream_package_name": "package",
             },
             PackageConfig(
+                downstream_package_name="package",
                 specfile_path="fedora/package.spec",
                 synced_files=SyncFilesConfig(
                     files_to_sync=[
@@ -444,8 +459,25 @@ def test_package_config_parse_error(raw):
                         SyncFilesItem(src="directory/files", dest="directory/files"),
                     ]
                 ),
-                jobs=[get_job_config_simple()],
-                downstream_package_name="package",
+                jobs=[
+                    get_job_config_simple(
+                        downstream_package_name="package",
+                        specfile_path="fedora/package.spec",
+                        synced_files=SyncFilesConfig(
+                            files_to_sync=[
+                                SyncFilesItem(
+                                    src="fedora/package.spec",
+                                    dest="fedora/package.spec",
+                                ),
+                                SyncFilesItem(src="somefile", dest="somefile"),
+                                SyncFilesItem(src="other", dest="other"),
+                                SyncFilesItem(
+                                    src="directory/files", dest="directory/files"
+                                ),
+                            ]
+                        ),
+                    )
+                ],
             ),
             id="specfile_path+synced_files+job_config_dict_simple+downstream_package_name",
         ),
@@ -457,6 +489,7 @@ def test_package_config_parse_error(raw):
                 "downstream_package_name": "package",
             },
             PackageConfig(
+                downstream_package_name="package",
                 specfile_path="fedora/package.spec",
                 synced_files=SyncFilesConfig(
                     files_to_sync=[
@@ -465,8 +498,20 @@ def test_package_config_parse_error(raw):
                         )
                     ]
                 ),
-                jobs=[get_job_config_full()],
-                downstream_package_name="package",
+                jobs=[
+                    get_job_config_full(
+                        downstream_package_name="package",
+                        specfile_path="fedora/package.spec",
+                        synced_files=SyncFilesConfig(
+                            files_to_sync=[
+                                SyncFilesItem(
+                                    src="fedora/package.spec",
+                                    dest="fedora/package.spec",
+                                )
+                            ]
+                        ),
+                    )
+                ],
             ),
             id="specfile_path+synced_files(spec_only)+job_config_full+downstream_package_name",
         ),
@@ -478,6 +523,7 @@ def test_package_config_parse_error(raw):
                 "downstream_package_name": "package",
             },
             PackageConfig(
+                downstream_package_name="package",
                 specfile_path="fedora/package.spec",
                 synced_files=SyncFilesConfig(
                     files_to_sync=[
@@ -487,8 +533,21 @@ def test_package_config_parse_error(raw):
                         SyncFilesItem(src="somefile", dest="somefile"),
                     ]
                 ),
-                jobs=[get_job_config_full()],
-                downstream_package_name="package",
+                jobs=[
+                    get_job_config_full(
+                        downstream_package_name="package",
+                        specfile_path="fedora/package.spec",
+                        synced_files=SyncFilesConfig(
+                            files_to_sync=[
+                                SyncFilesItem(
+                                    src="fedora/package.spec",
+                                    dest="fedora/package.spec",
+                                ),
+                                SyncFilesItem(src="somefile", dest="somefile"),
+                            ]
+                        ),
+                    )
+                ],
             ),
             id="specfile_path+synced_files+job_config_full+downstream_package_name",
         ),
@@ -503,7 +562,11 @@ def test_package_config_parse_error(raw):
                 "downstream_package_name": "package",
             },
             PackageConfig(
+                downstream_package_name="package",
                 specfile_path="fedora/package.spec",
+                upstream_project_url="https://github.com/asd/qwe",
+                upstream_package_name="qwe",
+                dist_git_base_url="https://something.wicked",
                 synced_files=SyncFilesConfig(
                     files_to_sync=[
                         SyncFilesItem(
@@ -511,11 +574,23 @@ def test_package_config_parse_error(raw):
                         )
                     ]
                 ),
-                jobs=[get_job_config_full()],
-                upstream_project_url="https://github.com/asd/qwe",
-                upstream_package_name="qwe",
-                dist_git_base_url="https://something.wicked",
-                downstream_package_name="package",
+                jobs=[
+                    get_job_config_full(
+                        downstream_package_name="package",
+                        specfile_path="fedora/package.spec",
+                        upstream_project_url="https://github.com/asd/qwe",
+                        upstream_package_name="qwe",
+                        dist_git_base_url="https://something.wicked",
+                        synced_files=SyncFilesConfig(
+                            files_to_sync=[
+                                SyncFilesItem(
+                                    src="fedora/package.spec",
+                                    dest="fedora/package.spec",
+                                )
+                            ]
+                        ),
+                    )
+                ],
             ),
             id="specfile_path+synced_files+job_config_dict_full+upstream_project_url"
             "+upstream_package_name+dist_git_base_url+downstream_package_name",
@@ -555,6 +630,7 @@ def test_package_config_parse_error(raw):
                 "downstream_package_name": "package",
             },
             PackageConfig(
+                downstream_package_name="package",
                 specfile_path="fedora/package.spec",
                 synced_files=SyncFilesConfig(
                     files_to_sync=[
@@ -563,19 +639,34 @@ def test_package_config_parse_error(raw):
                         )
                     ]
                 ),
-                jobs=get_default_job_config(),
-                downstream_package_name="package",
+                jobs=get_default_job_config(
+                    downstream_package_name="package",
+                    specfile_path="fedora/package.spec",
+                    synced_files=SyncFilesConfig(
+                        files_to_sync=[
+                            SyncFilesItem(
+                                src="fedora/package.spec", dest="fedora/package.spec"
+                            )
+                        ]
+                    ),
+                ),
             ),
             id="specfile_path+synced_files+downstream_package_name",
         ),
         pytest.param(
             {
                 "specfile_path": "fedora/package.spec",
+                "spec_source_id": 3,
                 "jobs": [get_job_config_dict_build_for_branch()],
             },
             PackageConfig(
                 specfile_path="fedora/package.spec",
-                jobs=[get_job_config_build_for_branch()],
+                spec_source_id="Source3",
+                jobs=[
+                    get_job_config_build_for_branch(
+                        specfile_path="fedora/package.spec", spec_source_id="Source3",
+                    )
+                ],
             ),
             id="specfile_path+get_job_config_dict_build_for_branch",
         ),
@@ -591,6 +682,102 @@ def test_package_config_parse(raw, expected):
     assert package_config == expected
 
 
+@pytest.mark.parametrize(
+    "raw,expected",
+    [
+        pytest.param(
+            {
+                "specfile_path": "fedora/package.spec",
+                "jobs": [
+                    {
+                        "job": "build",
+                        "trigger": "release",
+                        "specfile_path": "somewhere/package.spec",
+                    }
+                ],
+            },
+            PackageConfig(
+                specfile_path="fedora/package.spec",
+                jobs=[
+                    JobConfig(
+                        type=JobType.build,
+                        trigger=JobConfigTriggerType.release,
+                        specfile_path="somewhere/package.spec",
+                    )
+                ],
+            ),
+            id="override-specfile_path",
+        ),
+        pytest.param(
+            {
+                "specfile_path": "fedora/package.spec",
+                "synced_files": ["x", "y"],
+                "actions": {"post-upstream-clone": "ls"},
+                "spec_source_id": "Source0",
+                "jobs": [
+                    {
+                        "job": "build",
+                        "trigger": "release",
+                        "specfile_path": "somewhere/package.spec",
+                        "synced_files": ["a", "b", "c"],
+                        "actions": {"create-archive": "ls"},
+                        "spec_source_id": "Source1",
+                    }
+                ],
+            },
+            PackageConfig(
+                specfile_path="fedora/package.spec",
+                synced_files=SyncFilesConfig([SyncFilesItem(x, x) for x in ("x", "y")]),
+                actions={ActionName.post_upstream_clone: "ls"},
+                jobs=[
+                    JobConfig(
+                        type=JobType.build,
+                        trigger=JobConfigTriggerType.release,
+                        specfile_path="somewhere/package.spec",
+                        synced_files=SyncFilesConfig(
+                            [SyncFilesItem(x, x) for x in ("a", "b", "c")]
+                        ),
+                        actions={ActionName.create_archive: "ls"},
+                        spec_source_id="Source1",
+                    )
+                ],
+            ),
+            id="override-alot",
+        ),
+    ],
+)
+def test_package_config_overrides(raw, expected):
+    package_config = PackageConfig.get_from_dict(raw_dict=raw)
+    assert package_config == expected
+
+
+@pytest.mark.parametrize(
+    "raw,err_message",
+    [
+        pytest.param(
+            {
+                "specfile_path": "fedora/package.spec",
+                "jobs": [{"job": "build", "trigger": "release", "actions": ["a"]}],
+            },
+            "'dict' required, got <class 'list'>.",
+            id="bad_actions",
+        ),
+        pytest.param(
+            {
+                "specfile_path": "fedora/package.spec",
+                "jobs": [{"job": "build", "trigger": "release", "synced_files": "a"}],
+            },
+            "Invalid input type.",
+            id="bad_synced_files",
+        ),
+    ],
+)
+def test_package_config_overrides_bad(raw, err_message):
+    with pytest.raises(ValidationError) as ex:
+        PackageConfig.get_from_dict(raw_dict=raw)
+    assert err_message in str(ex)
+
+
 @pytest.mark.parametrize(
     "raw,expected",
     [
@@ -642,7 +829,18 @@ def test_dist_git_package_url():
         ),
         specfile_path="fedora/package.spec",
         create_pr=False,
-        jobs=get_default_job_config(),
+        jobs=get_default_job_config(
+            dist_git_base_url="https://packit.dev/",
+            downstream_package_name="packit",
+            dist_git_namespace="awesome",
+            synced_files=SyncFilesConfig(
+                files_to_sync=[
+                    SyncFilesItem(src="fedora/foobar.spec", dest="fedora/foobar.spec")
+                ]
+            ),
+            specfile_path="fedora/package.spec",
+            create_pr=False,
+        ),
     )
     assert new_pc.specfile_path.endswith("fedora/package.spec")
     assert pc.specfile_path.endswith("fedora/package.spec")
@@ -784,23 +982,35 @@ def test_get_local_specfile_path():
     [
         PackageConfig(
             specfile_path="fedora/package.spec",
+            downstream_package_name="package",
+            upstream_package_name="package",
             synced_files=SyncFilesConfig(
                 files_to_sync=[
                     SyncFilesItem(src="fedora/package.spec", dest="fedora/package.spec")
                 ]
             ),
-            downstream_package_name="package",
-            upstream_package_name="package",
         ),
         PackageConfig(
             specfile_path="fedora/package.spec",
+            downstream_package_name="package",
             synced_files=SyncFilesConfig(
                 files_to_sync=[
                     SyncFilesItem(src="fedora/package.spec", dest="fedora/package.spec")
                 ]
             ),
-            jobs=[get_job_config_full()],
-            downstream_package_name="package",
+            jobs=[
+                get_job_config_full(
+                    specfile_path="fedora/package.spec",
+                    downstream_package_name="package",
+                    synced_files=SyncFilesConfig(
+                        files_to_sync=[
+                            SyncFilesItem(
+                                src="fedora/package.spec", dest="fedora/package.spec"
+                            )
+                        ]
+                    ),
+                )
+            ],
         ),
         PackageConfig(
             specfile_path="fedora/package.spec",
@@ -813,6 +1023,7 @@ def test_get_local_specfile_path():
             upstream_package_name="qwe",
             dist_git_base_url="https://something.wicked",
             downstream_package_name="package",
+            spec_source_id="Source1",
         ),
     ],
 )