hape 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hape/__init__.py ADDED
File without changes
hape/bootstrap.py ADDED
@@ -0,0 +1,18 @@
1
+ from hape.logging import logger
2
+ from hape.config import Config
3
+ from hape.models.deployment_cost_model import DeploymentCost
4
+
5
+ def bootstrap_application():
6
+ logger.info("Checking configurations.")
7
+ Config.check_variables()
8
+
9
+ session = Config.get_db_session()
10
+ try:
11
+ DeploymentCost.initialize_from_sqlalchemy(DeploymentCost)
12
+ logger.info("Database initialized successfully!")
13
+ except Exception as e:
14
+ logger.error(f"Error initializing database: {e}")
15
+ finally:
16
+ session.close()
17
+
18
+ logger.info("Application started!")
hape/cli.py ADDED
@@ -0,0 +1,19 @@
1
+ from hape.bootstrap import bootstrap_application
2
+ from hape.argument_parsers.main_argument_parser import MainArgumentParser
3
+
4
+ class CLI:
5
+
6
+ @classmethod
7
+ def main():
8
+ bootstrap_application()
9
+
10
+ main_parser = MainArgumentParser()
11
+
12
+ parser = main_parser.create_parser()
13
+ args = parser.parse_args()
14
+
15
+ if args.command is None:
16
+ parser.print_help()
17
+ return
18
+
19
+ main_parser.run_action(args)
hape/config.py ADDED
@@ -0,0 +1,89 @@
1
+ import os
2
+ import json
3
+ from dotenv import load_dotenv
4
+ from sqlalchemy import create_engine
5
+ from sqlalchemy.orm import sessionmaker
6
+ from sqlalchemy.exc import OperationalError
7
+ from sqlalchemy.sql import text
8
+ from hape.logging import logger
9
+
10
+
11
+
12
+ class Config:
13
+ _env_loaded = False
14
+ _db_session = None
15
+ _required_env_variables = ["HAPE_GITLAB_TOKEN", "HAPE_GITLAB_DOMAIN", "HAPE_MARIADB_HOST", "HAPE_MARIADB_USERNAME", "HAPE_MARIADB_PASSWORD", "HAPE_MARIADB_DATABASE"]
16
+
17
+ @staticmethod
18
+ def check_variables():
19
+ for variable in Config._required_env_variables:
20
+ Config._get_env_value(variable)
21
+
22
+ @staticmethod
23
+ def _load_environment():
24
+ if not Config._env_loaded:
25
+ if os.path.exists(".env"):
26
+ load_dotenv()
27
+ Config._env_loaded = True
28
+
29
+ @staticmethod
30
+ def _get_env_value(env):
31
+ Config._load_environment()
32
+ env_value = os.getenv(env)
33
+
34
+ if not env_value and env in Config._required_env_variables:
35
+ logger.error(f"""One or more of the required environment variables is missing.
36
+
37
+ To set the value of the environment variable run:
38
+ $ export ENV_VARIABLE_NAME="value"
39
+
40
+ The following environment variables are required:
41
+ {json.dumps(Config._required_env_variables, indent=4)}
42
+ """)
43
+ exit(1)
44
+ return env_value
45
+
46
+ @staticmethod
47
+ def get_db_url():
48
+ return f"mysql+pymysql://{Config.get_mysql_username()}:{Config.get_mysql_password()}@{Config.get_mysql_host()}/{Config.get_mysql_database()}"
49
+
50
+ @staticmethod
51
+ def get_db_session() -> sessionmaker:
52
+ if not Config._db_session:
53
+ try:
54
+ DATABASE_URL = Config.get_db_url()
55
+ engine = create_engine(DATABASE_URL, echo=True)
56
+ with engine.connect() as connection:
57
+ connection.execute(text("SELECT 1"))
58
+
59
+ Config._db_session = sessionmaker(autocommit=False, autoflush=False, bind=engine)
60
+ logger.info("Database seassion created successfully.")
61
+ except OperationalError:
62
+ logger.error("Error: Unable to connect to the database. Please check the configuration.")
63
+ raise
64
+
65
+ return Config._db_session()
66
+
67
+ @staticmethod
68
+ def get_gitlab_token():
69
+ return Config._get_env_value("HAPE_GITLAB_TOKEN")
70
+
71
+ @staticmethod
72
+ def get_gitlab_domain():
73
+ return Config._get_env_value("HAPE_GITLAB_DOMAIN")
74
+
75
+ @staticmethod
76
+ def get_mysql_host():
77
+ return Config._get_env_value("HAPE_MARIADB_HOST")
78
+
79
+ @staticmethod
80
+ def get_mysql_username():
81
+ return Config._get_env_value("HAPE_MARIADB_USERNAME")
82
+
83
+ @staticmethod
84
+ def get_mysql_password():
85
+ return Config._get_env_value("HAPE_MARIADB_PASSWORD")
86
+
87
+ @staticmethod
88
+ def get_mysql_database():
89
+ return Config._get_env_value("HAPE_MARIADB_DATABASE")
File without changes
@@ -0,0 +1,7 @@
1
+ from hape.base.model_controller import ModelController
2
+ from hape.models.deployment_cost_model import DeploymentCost
3
+
4
+ class DeploymentCostController(ModelController):
5
+
6
+ def __init__(self):
7
+ super().__init__(DeploymentCost)
@@ -0,0 +1,39 @@
1
+ class GitController:
2
+ def __init__(self):
3
+ self.git_model = GitModel()
4
+
5
+ def clone_project_in_group(self, dir, group_id):
6
+ return self.git_model.clone_project_in_group(dir, group_id)
7
+
8
+ def commit_projects(self, dir, message, prefix):
9
+ repos = self.git_model.get_repositories(dir, prefix)
10
+
11
+ print("The following projects will be committed and pushed:")
12
+ print(json.dumps(repos, indent=2))
13
+ print(f"Total number of affected projects: {len(repos)}")
14
+
15
+ user_input = input("Do you want to continue? (y/n): ").strip().lower()
16
+ if user_input != "y":
17
+ print("Aborting task...")
18
+ return
19
+
20
+ for repo in repos:
21
+ print("==============================")
22
+ print(f"Directory: {repo}")
23
+ if self.git_model.commit_and_push(repo, message):
24
+ print("Changes committed and pushed successfully.")
25
+ else:
26
+ print("No changes to commit.")
27
+
28
+ def pull_projects(self, dir, prefix):
29
+ repos = self.git_model.get_repositories(dir, prefix)
30
+
31
+ print("The following projects will be pulled:")
32
+ print(json.dumps(repos, indent=2))
33
+ print(f"Total number of affected projects: {len(repos)}")
34
+
35
+ for repo in repos:
36
+ print("==============================")
37
+ print(f"Directory: {repo}")
38
+ self.git_model.pull_project(repo)
39
+ print("Repository updated successfully.")
hape/enums/__init__.py ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ import enum
2
+
3
+ class KubeContextEnum(enum.Enum):
4
+ dev = "dev"
5
+ test = "test"
6
+ prod = "prod"
File without changes
File without changes
@@ -0,0 +1,21 @@
1
+ from hape.hape_cli.controllers.init_controller import InitController
2
+
3
+ class InitArgumentParser:
4
+ def __init__(self):
5
+ self.COMMAND = "init"
6
+
7
+ def create_subparser(self, subparsers):
8
+ init_parser = subparsers.add_parser(self.COMMAND, help="Commands related to initializing the workspace")
9
+ init_parser_subparser = init_parser.add_subparsers(dest="action")
10
+
11
+ project_parser = init_parser_subparser.add_parser("project", help="Initializes a new project")
12
+ project_parser.add_argument("-n", "--name", required=True, help="Name of the project")
13
+
14
+ def run_action(self, args):
15
+ if args.command != self.COMMAND:
16
+ return
17
+ if args.action == "project":
18
+ InitController(args.name).init_project()
19
+ else:
20
+ print(f"Error: Invalid {args.command} action. Use `hape {args.command} --help` for more details.")
21
+ exit(1)
@@ -0,0 +1,28 @@
1
+ import argparse
2
+ from importlib.metadata import version
3
+
4
+ from hape.hape_cli.argument_parsers.init_argument_parser import InitArgumentParser
5
+
6
+ class MainArgumentParser:
7
+
8
+ def create_parser(self):
9
+ parser = argparse.ArgumentParser(description="HAPE Framework CLI")
10
+
11
+ try:
12
+ parser.add_argument("-v", "--version", action="version", version=version("hape"))
13
+ except:
14
+ parser.add_argument("-v", "--version", action="version", version="0.0.0")
15
+
16
+ subparsers = parser.add_subparsers(dest="command")
17
+
18
+ InitArgumentParser().create_subparser(subparsers)
19
+
20
+ return parser
21
+
22
+ def run_action(self, args):
23
+
24
+ if args.command == "init":
25
+ InitArgumentParser().run_action(args)
26
+ else:
27
+ print(f"Error: Invalid command {args.command}")
28
+ exit(1)
hape/hape_cli/cli.py ADDED
@@ -0,0 +1,19 @@
1
+ from hape.bootstrap import bootstrap_application
2
+ from hape.hape_cli.argument_parsers.main_argument_parser import MainArgumentParser
3
+
4
+ class CLI:
5
+
6
+ @classmethod
7
+ def main(cls):
8
+ bootstrap_application()
9
+
10
+ main_parser = MainArgumentParser()
11
+
12
+ parser = main_parser.create_parser()
13
+ args = parser.parse_args()
14
+
15
+ if args.command is None:
16
+ parser.print_help()
17
+ return
18
+
19
+ main_parser.run_action(args)
File without changes
@@ -0,0 +1,11 @@
1
+ from hape.hape_cli.models.init_model import Init
2
+ import re
3
+
4
+ class InitController:
5
+
6
+ def __init__(self, name):
7
+ self.__init = Init(name)
8
+ self.__init.validate()
9
+
10
+ def init_project(self):
11
+ self.__init.init_project()
File without changes
@@ -0,0 +1,107 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ from hape.logging import logger
5
+ from hape.services.file_service import FileService
6
+
7
+ class Init:
8
+
9
+ def __init__(self, name: str):
10
+ self.name = name
11
+ self.name_underscore = name.replace("-", "_")
12
+ self.file_service = FileService()
13
+ self.hape_framework_path = os.path.dirname(os.path.abspath(sys.argv[0]))
14
+ self.hape_files = [
15
+ ".dockerignore",
16
+ ".env.example",
17
+ ".gitignore",
18
+ "alembic.ini",
19
+ "main.py",
20
+ "Makefile",
21
+ "requirements-dev.txt",
22
+ "setup.py"
23
+ ]
24
+ self.hape_dirs = [
25
+ "dockerfiles",
26
+ "scripts",
27
+ ]
28
+
29
+ self.PROJECT_STRUCTURE = {
30
+ ".dockerignore": None,
31
+ ".env.example": None,
32
+ ".gitignore": None,
33
+ "alembic.ini": None,
34
+ "main.py": None,
35
+ "Makefile": None,
36
+ "README.md": None,
37
+ "requirements-dev.txt": None,
38
+ "setup.py": None,
39
+ "dockerfiles": [],
40
+ "scripts": [],
41
+ self.name_underscore: {
42
+ "argument_parsers": [".gitkeep"],
43
+ "cli.py": None,
44
+ "controllers": ["__init__.py"],
45
+ "enums": ["__init__.py"],
46
+ "migrations": ["README", "env.py", "script.py.mako"],
47
+ "migrations/versions": [".gitkeep"],
48
+ "models": ["__init__.py"],
49
+ "services": ["__init__.py"],
50
+ }
51
+ }
52
+
53
+ def __copy_hape_files(self):
54
+ for file in self.hape_files:
55
+ src_file = os.path.join(self.hape_framework_path, file)
56
+ dest_file = os.path.join(self.name, file)
57
+ logger.debug(f'Copying file: {src_file} -> {dest_file}')
58
+ self.file_service.copy_file(src_file, dest_file, overwrite=True)
59
+
60
+ for directory in self.hape_dirs:
61
+ src_dir = os.path.join(self.hape_framework_path, directory)
62
+ dest_dir = os.path.join(self.name, directory)
63
+ logger.debug(f'Copying directory: {src_dir} -> {dest_dir}')
64
+ self.file_service.copy_directory(src_dir, dest_dir)
65
+
66
+ def __init_project_structure_process_dictionary(self, dictionary: dict, root_path: str):
67
+ for key, value in dictionary.items():
68
+ sub_path = os.path.join(root_path, key)
69
+ if value is None:
70
+ logger.debug(f'Creating file: {sub_path}')
71
+ self.file_service.write_file(sub_path, "")
72
+ elif isinstance(value, str):
73
+ logger.debug(f'Creating file: {sub_path}')
74
+ self.file_service.write_file(sub_path, value)
75
+ elif isinstance(value, list):
76
+ logger.debug(f'Creating directory: {sub_path}')
77
+ self.file_service.create_directory(sub_path)
78
+ for file in value:
79
+ logger.debug(f'Creating file: {sub_path}')
80
+ self.file_service.write_file(os.path.join(sub_path, file), "")
81
+ elif isinstance(value, dict):
82
+ self.__init_project_structure_process_dictionary(value, sub_path)
83
+
84
+ def __init_project_structure(self):
85
+ logger.debug(f'Creating directory: {self.name}')
86
+ if self.file_service.path_exists(self.name):
87
+ logger.error(f"Error: directory '{self.name}' already exists.")
88
+ sys.exit(1)
89
+ self.file_service.create_directory(self.name)
90
+ self.__init_project_structure_process_dictionary(self.PROJECT_STRUCTURE, self.name)
91
+
92
+ readme_path = os.path.join(self.name, "README.md")
93
+ logger.debug(f'Creating file: {readme_path}')
94
+ self.file_service.write_file(readme_path, f"# {self.name}\n")
95
+
96
+ def validate(self):
97
+ name = self.name.strip()
98
+ logger.debug(f'Validating project name: {self.name}')
99
+ if not re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name):
100
+ logger.error(f"Error: Project name '{name}' must contain only lowercase letters, numbers, and use '-' as a separator.")
101
+ sys.exit(1)
102
+ logger.debug(f'Valid project name.')
103
+
104
+ def init_project(self):
105
+ self.__init_project_structure()
106
+ self.__copy_hape_files()
107
+ logger.info(f'Project {self.name} has been successfully initialized!')
hape/logging.py ADDED
@@ -0,0 +1,59 @@
1
+ import logging
2
+ import logging.config
3
+ import os
4
+ from pythonjsonlogger import jsonlogger
5
+
6
+ LOG_DIR = "logs"
7
+ LOG_FILE = os.path.join(LOG_DIR, "app.log")
8
+
9
+ if not os.path.exists(LOG_DIR):
10
+ os.makedirs(LOG_DIR)
11
+
12
+ class CustomJsonFormatter(jsonlogger.JsonFormatter):
13
+ def parse(self):
14
+ return ["timestamp", "level", "name", "message", "module", "funcName", "lineno"]
15
+
16
+ LOGGING_CONFIG = {
17
+ "version": 1,
18
+ "disable_existing_loggers": False,
19
+ "formatters": {
20
+ "standard": {
21
+ "format": "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
22
+ },
23
+ "json": {
24
+ "()": CustomJsonFormatter,
25
+ "fmt": "%(timestamp)s %(level)s %(name)s %(message)s %(module)s %(funcName)s %(lineno)d"
26
+ }
27
+ },
28
+ "handlers": {
29
+ "console": {
30
+ "class": "logging.StreamHandler",
31
+ "formatter": "standard",
32
+ "level": "DEBUG"
33
+ },
34
+ "file": {
35
+ "class": "logging.handlers.RotatingFileHandler",
36
+ "formatter": "json",
37
+ "level": "INFO",
38
+ "filename": LOG_FILE,
39
+ "maxBytes": 10485760,
40
+ "backupCount": 5
41
+ }
42
+ },
43
+ "loggers": {
44
+ "": {
45
+ "handlers": ["console", "file"],
46
+ "level": "DEBUG",
47
+ "propagate": False
48
+ },
49
+ "uvicorn": {
50
+ "handlers": ["console", "file"],
51
+ "level": "INFO",
52
+ "propagate": False
53
+ }
54
+ }
55
+ }
56
+
57
+ logging.config.dictConfig(LOGGING_CONFIG)
58
+ logger = logging.getLogger("global_logger")
59
+ logger.info("Global logger initialized.")
File without changes
@@ -0,0 +1,26 @@
1
+ from sqlalchemy import Column, Integer, String, Float, Boolean, BigInteger
2
+ from datetime import datetime
3
+ from hape.base.model import Model
4
+
5
+ class DeploymentCost(Model):
6
+ __tablename__ = 'deployment_costs'
7
+
8
+ id = Column(Integer, primary_key=True, autoincrement=True)
9
+ created_at = Column(BigInteger, default=lambda: int(datetime.utcnow().timestamp()), nullable=False)
10
+ service_name = Column(String(255), nullable=False)
11
+ pod_cpu = Column(String(50), nullable=False)
12
+ pod_ram = Column(String(50), nullable=False)
13
+ autoscaling = Column(Boolean, nullable=False)
14
+ min_replicas = Column(Integer, nullable=True)
15
+ max_replicas = Column(Integer, nullable=True)
16
+ current_replicas = Column(Integer, nullable=False)
17
+ pod_cost = Column(Float, nullable=False)
18
+ number_of_pods = Column(Integer, nullable=False)
19
+ total_cost = Column(Float, nullable=False)
20
+ cost_unit = Column(String(50), nullable=False)
21
+
22
+ def __init__(self, **kwargs):
23
+ filtered_kwargs = {key: kwargs[key] for key in self.__table__.columns.keys() if key in kwargs}
24
+ super().__init__(**filtered_kwargs)
25
+ for key, value in filtered_kwargs.items():
26
+ setattr(self, key, value)
@@ -0,0 +1,31 @@
1
+ from hape.services.git_service import GitService
2
+
3
+ class GitModel:
4
+ def __init__(self):
5
+ self._git_service = GitService()
6
+
7
+ def clone_project_in_group(self, dir, group_id):
8
+ self._git_service.clone_all_projects(group_id, dir)
9
+
10
+ def get_repositories(self, dir, prefix):
11
+ return self._git_service.get_git_repositories(dir, prefix)
12
+
13
+ def git_has_changes(self, repo):
14
+ return self._git_service.git_has_changes(repo)
15
+
16
+ def git_branch_name(self, repo):
17
+ return self._git_service.git_branch_name(repo)
18
+
19
+ def commit_and_push(self, repo, message):
20
+ if self.git_has_changes(repo):
21
+ branch_name = self.git_branch_name(repo)
22
+ self._git_service.git_pull(repo, branch_name)
23
+ self._git_service.git_add(repo)
24
+ self._git_service.git_commit(repo, message)
25
+ self._git_service.git_push(repo, branch_name)
26
+ return True
27
+ return False
28
+
29
+ def pull_project(self, repo):
30
+ branch_name = self.git_branch_name(repo)
31
+ self._git_service.git_pull(repo, branch_name)
@@ -0,0 +1,61 @@
1
+ import re
2
+ from typing import List, Optional
3
+ from datetime import datetime
4
+ from gitlab import Gitlab
5
+ from gitlab.v4.objects import Group, Project, GroupProject, ProjectCommit
6
+ from hape.config import Config
7
+ from hape.services.file_service import FileService
8
+
9
+ class GitlabService:
10
+ def __init__(self):
11
+ gitlab_domain = Config.get_gitlab_domain()
12
+ gitlab_token = Config.get_gitlab_token()
13
+ self.client = Gitlab(f"https://{gitlab_domain}", private_token=gitlab_token)
14
+ self._per_page = 100
15
+
16
+ def _get_project(self, id: int) -> Project:
17
+ return self.client.projects.get(id)
18
+
19
+ def _get_projects(self, ids: list[int]) -> List[Project]:
20
+ return [self.client.projects.get(id) for id in ids]
21
+
22
+ def _get_group_projects(self, group_id: int, recursive: bool = False, search: Optional[str] = None) -> List[GroupProject]:
23
+ group: Group = self.client.groups.get(group_id)
24
+ return group.projects.list(get_all=True, per_page=self._per_page, include_subgroups=recursive, search=search)
25
+
26
+ def _get_project_commits(self, project: Project, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None) -> List[ProjectCommit]:
27
+ params = {"get_all": True}
28
+ if start_date:
29
+ params["since"] = start_date.isoformat()
30
+ if end_date:
31
+ params["until"] = end_date.isoformat()
32
+
33
+ return project.commits.list(**params)
34
+
35
+ def generate_csv_changes_in_cicd_repos(self, group_id: int, output_file: str, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, file_regex: Optional[str] = None):
36
+
37
+ group_projects = self._get_group_projects(group_id, recursive=True, search="cicd-")
38
+ project_ids = [project.id for project in group_projects]
39
+ projects = self._get_projects(project_ids)
40
+
41
+ all_commits = []
42
+ for project in projects:
43
+ project_path = project.path.replace("cicd-", "")
44
+ commits = self._get_project_commits(project, start_date, end_date)
45
+ for commit in commits:
46
+ if commit.author_name == f"{project_path}-ci":
47
+ continue
48
+
49
+ diff = commit.diff()
50
+ for change in diff:
51
+ if file_regex and re.match(file_regex, change['new_path']):
52
+ all_commits.append({
53
+ "project_name": project.name,
54
+ "commit_date": commit.committed_date,
55
+ "commit_author": commit.author_name,
56
+ "commit": commit.id,
57
+ "web_url": commit.web_url
58
+ })
59
+
60
+ FileService().write_csv_file(output_file, all_commits)
61
+ print(f"CSV report generated: {output_file}")
hape/playground.py ADDED
@@ -0,0 +1,72 @@
1
+ import json
2
+ from datetime import datetime
3
+ from hape.models.deployment_cost_model import DeploymentCost
4
+ from hape.controllers.deployment_cost_controller import DeploymentCostController
5
+ from hape.services.gitlab_service import GitlabService
6
+ from hape.services.file_service import FileService
7
+
8
+ class Playground:
9
+
10
+ deployment_cost_controller = DeploymentCostController()
11
+
12
+ @classmethod
13
+ def main(self):
14
+ playground = Playground()
15
+ playground.play()
16
+
17
+ def get_all_deployment_costs(self):
18
+ deployment_costs = DeploymentCost.get_all()
19
+ print(DeploymentCost.list_to_json(deployment_costs))
20
+
21
+ def save_deployment_cost(self):
22
+ deployment_cost = DeploymentCost(
23
+ service_name="Test Service",
24
+ pod_cpu="2",
25
+ pod_ram="4Gi",
26
+ autoscaling=True,
27
+ min_replicas=1,
28
+ max_replicas=5,
29
+ current_replicas=3,
30
+ pod_cost=0.10,
31
+ number_of_pods=3,
32
+ total_cost=0.30,
33
+ cost_unit="$"
34
+ )
35
+ if self.deployment_cost_controller.save(deployment_cost):
36
+ print('-----saved successfully-----')
37
+ else:
38
+ print('-----saving failed-----')
39
+
40
+ print(deployment_cost.json())
41
+
42
+ def delete_deployment_cost(self):
43
+ deployment_cost = DeploymentCost.get(id=2)
44
+ if not deployment_cost:
45
+ print("Object id=2 does not exist!")
46
+ return
47
+ print(deployment_cost.json())
48
+ deployment_cost.delete()
49
+
50
+ def delete_all_deployment_cost(self):
51
+ print("delete all where id in [1,4] and service_name='Test Service'")
52
+ DeploymentCost.delete_all(id=["1", "4"], service_name="Test Service")
53
+
54
+ def generate_gitlab_changes_report(self):
55
+ gitlab = GitlaService()
56
+ start_date = datetime(2025, 2, 3)
57
+ end_date = datetime(2025, 2, 5)
58
+ gitlab.generate_csv_changes_in_cicd_repos(
59
+ group_id=178,
60
+ start_date=start_date,
61
+ end_date=end_date,
62
+ output_file="/Users/hazemataya/Desktop/workspace/innodp/playground/test.csv",
63
+ file_regex=r".*values.*.yaml"
64
+ )
65
+
66
+ def play(self):
67
+ Playground().save_deployment_cost()
68
+ Playground().get_all_deployment_costs()
69
+ Playground().delete_deployment_cost()
70
+ Playground().delete_all_deployment_cost()
71
+ Playground().generate_gitlab_changes_report()
72
+ pass
File without changes