fabric-cicd 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Microsoft Corporation.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.2
2
+ Name: fabric-cicd
3
+ Version: 0.1.0
4
+ Summary: Microsoft Fabric CI/CD
5
+ Author: Microsoft Corporation
6
+ License: MIT License
7
+
8
+ Copyright (c) Microsoft Corporation.
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE
27
+
28
+ Project-URL: Repository, https://github.com/microsoft/fabric-cicd.git
29
+ Requires-Python: <3.13,>=3.9
30
+ Description-Content-Type: text/markdown
31
+ License-File: LICENSE
32
+ Requires-Dist: azure-identity>=1.19.0
33
+ Requires-Dist: colorlog>=6.9.0
34
+ Requires-Dist: pyyaml>=6.0.2
35
+ Requires-Dist: requests>=2.32.3
36
+
37
+ # Fabric CICD
38
+
39
+ [![Language](https://img.shields.io/badge/language-Python-blue.svg)](https://www.python.org/)
40
+ [![PyPI Version](https://badge.fury.io/py/fabric-cicd.svg)](https://badge.fury.io/py/fabric-cicd)
41
+ [![Python Versions](https://img.shields.io/pypi/pyversions/fabric-cicd.svg)](https://pypi.org/project/fabric-cicd/)
42
+ [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/charliermarsh/ruff)
43
+
44
+ ---
45
+
46
+ ## Project Overview
47
+
48
+ fabric-cicd is a Python library designed for use with [Microsoft Fabric](https://learn.microsoft.com/en-us/fabric/) workspaces. This library supports code-first Continuous Integration / Continuous Deployment (CI/CD) automations to seamlessly integrate Source Controlled workspaces into a deployment framework. The goal is to assist CI/CD developers who prefer not to interact directly with the Microsoft Fabric APIs.
49
+
50
+ ## Documentation
51
+
52
+ All documentation is hosted on our [fabric-cicd](https://microsoft.github.io/fabric-cicd/) GitHub Pages
53
+
54
+ Section Overview:
55
+ - [Home](https://microsoft.github.io/fabric-cicd/latest/)
56
+ - [How To](https://microsoft.github.io/fabric-cicd/latest/how_to/)
57
+ - [Contribution](https://microsoft.github.io/fabric-cicd/latest/contribution/)
58
+ - [Changelog](https://microsoft.github.io/fabric-cicd/latest/changelog/)
59
+ - [About](https://microsoft.github.io/fabric-cicd/latest/help/) - Inclusive of Support & Security Policies
60
+
61
+ ## Installation
62
+
63
+ To install fabric-cicd, run:
64
+
65
+ ```bash
66
+ pip install fabric-cicd
67
+ ```
68
+
69
+ ## Trademarks
70
+
71
+ This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies.
@@ -0,0 +1,35 @@
1
+ # Fabric CICD
2
+
3
+ [![Language](https://img.shields.io/badge/language-Python-blue.svg)](https://www.python.org/)
4
+ [![PyPI Version](https://badge.fury.io/py/fabric-cicd.svg)](https://badge.fury.io/py/fabric-cicd)
5
+ [![Python Versions](https://img.shields.io/pypi/pyversions/fabric-cicd.svg)](https://pypi.org/project/fabric-cicd/)
6
+ [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/charliermarsh/ruff)
7
+
8
+ ---
9
+
10
+ ## Project Overview
11
+
12
+ fabric-cicd is a Python library designed for use with [Microsoft Fabric](https://learn.microsoft.com/en-us/fabric/) workspaces. This library supports code-first Continuous Integration / Continuous Deployment (CI/CD) automations to seamlessly integrate Source Controlled workspaces into a deployment framework. The goal is to assist CI/CD developers who prefer not to interact directly with the Microsoft Fabric APIs.
13
+
14
+ ## Documentation
15
+
16
+ All documentation is hosted on our [fabric-cicd](https://microsoft.github.io/fabric-cicd/) GitHub Pages
17
+
18
+ Section Overview:
19
+ - [Home](https://microsoft.github.io/fabric-cicd/latest/)
20
+ - [How To](https://microsoft.github.io/fabric-cicd/latest/how_to/)
21
+ - [Contribution](https://microsoft.github.io/fabric-cicd/latest/contribution/)
22
+ - [Changelog](https://microsoft.github.io/fabric-cicd/latest/changelog/)
23
+ - [About](https://microsoft.github.io/fabric-cicd/latest/help/) - Inclusive of Support & Security Policies
24
+
25
+ ## Installation
26
+
27
+ To install fabric-cicd, run:
28
+
29
+ ```bash
30
+ pip install fabric-cicd
31
+ ```
32
+
33
+ ## Trademarks
34
+
35
+ This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies.
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "fabric-cicd"
3
+ authors = [{ name = "Microsoft Corporation" }]
4
+ description = "Microsoft Fabric CI/CD"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9,<3.13"
7
+ license = { file = "LICENSE" }
8
+ dynamic = ["version"]
9
+ urls.Repository = "https://github.com/microsoft/fabric-cicd.git"
10
+
11
+ dependencies = [
12
+ "azure-identity>=1.19.0",
13
+ "colorlog>=6.9.0",
14
+ "pyyaml>=6.0.2",
15
+ "requests>=2.32.3",
16
+ ]
17
+
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "gitpython>=3.1.44",
22
+ "mike>=2.1.3",
23
+ "mkdocs-include-markdown-plugin>=7.1.2",
24
+ "mkdocs-minify-plugin>=0.8.0",
25
+ "mkdocstrings-python>=1.13.0",
26
+ ]
27
+
28
+
29
+ [build-system]
30
+ requires = ["setuptools>=61.0"]
31
+ build-backend = "setuptools.build_meta"
32
+
33
+ [tool.setuptools]
34
+ dynamic.version = { file = "VERSION" }
35
+ packages.find.where = ["src"]
36
+
37
+ [tool.uv]
38
+ package = true
39
+ python-preference = "only-managed"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,54 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ """Provides tools for managing and publishing items in a Fabric workspace."""
5
+
6
+ import logging
7
+ import sys
8
+
9
+ from fabric_cicd._common._logging import configure_logger, exception_handler
10
+ from fabric_cicd.fabric_workspace import FabricWorkspace
11
+ from fabric_cicd.publish import publish_all_items, unpublish_all_orphan_items
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def change_log_level(level: str = "DEBUG") -> None:
17
+ """
18
+ Sets the log level for all loggers within the fabric_cicd package. Currently only supports DEBUG.
19
+
20
+ Parameters
21
+ ----------
22
+ level : str
23
+ The logging level to set (e.g., DEBUG).
24
+
25
+ Examples
26
+ --------
27
+ Basic usage
28
+ >>> from fabric_cicd import FabricWorkspace, publish_all_items, unpublish_all_orphan_items, change_log_level
29
+ >>> change_log_level("DEBUG")
30
+ >>> workspace = FabricWorkspace(
31
+ ... workspace_id="your-workspace-id",
32
+ ... repository_directory="/path/to/repo",
33
+ ... item_type_in_scope=["Environment", "Notebook", "DataPipeline"]
34
+ ... )
35
+ >>> publish_all_items(workspace)
36
+ >>> unpublish_orphaned_items(workspace)
37
+
38
+ """
39
+ if level.upper() == "DEBUG":
40
+ configure_logger(logging.DEBUG)
41
+ logger.info("Changed log level to DEBUG")
42
+ else:
43
+ logger.warning(f"Log level '{level}' not supported. Only DEBUG is supported at this time. No changes made.")
44
+
45
+
46
+ configure_logger()
47
+ sys.excepthook = exception_handler
48
+
49
+ __all__ = [
50
+ "FabricWorkspace",
51
+ "change_log_level",
52
+ "publish_all_items",
53
+ "unpublish_all_orphan_items",
54
+ ]
@@ -0,0 +1,3 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
@@ -0,0 +1,29 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+
5
+ class BaseCustomError(Exception):
6
+ def __init__(self, message, logger, additional_info=None):
7
+ super().__init__(message)
8
+ self.logger = logger
9
+ self.additional_info = additional_info
10
+
11
+
12
+ class ParsingError(BaseCustomError):
13
+ pass
14
+
15
+
16
+ class InputError(BaseCustomError):
17
+ pass
18
+
19
+
20
+ class TokenError(BaseCustomError):
21
+ pass
22
+
23
+
24
+ class InvokeError(BaseCustomError):
25
+ pass
26
+
27
+
28
+ class ItemDependencyError(BaseCustomError):
29
+ pass
@@ -0,0 +1,264 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ import base64
5
+ import datetime
6
+ import json
7
+ import logging
8
+ import time
9
+
10
+ import requests
11
+ from azure.core.exceptions import (
12
+ ClientAuthenticationError,
13
+ )
14
+
15
+ from fabric_cicd._common._exceptions import InvokeError, TokenError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class FabricEndpoint:
21
+ """Handles interactions with the Fabric API, including authentication and request management."""
22
+
23
+ def __init__(self, token_credential):
24
+ """Initializes the FabricEndpoint instance, sets up the authentication token."""
25
+ self.aad_token = None
26
+ self.aad_token_expiration = None
27
+ self.token_credential = token_credential
28
+ self._refresh_token()
29
+
30
+ def invoke(self, method, url, body="{}", files=None):
31
+ """
32
+ Sends an HTTP request to the specified URL with the given method and body.
33
+
34
+ :param method: HTTP method to use for the request (e.g., 'GET', 'POST', 'PATCH', 'DELETE').
35
+ :param url: URL to send the request to.
36
+ :param body: The JSON body to include in the request. Defaults to an empty JSON object.
37
+ :param files: The file path to be included in the request. Defaults to None.
38
+ :return: A dictionary containing the response headers, body, and status code.
39
+ """
40
+ exit_loop = False
41
+ iteration_count = 0
42
+ long_running = False
43
+
44
+ while not exit_loop:
45
+ try:
46
+ if files is None:
47
+ headers = {
48
+ "Authorization": f"Bearer {self.aad_token}",
49
+ "Content-Type": "application/json; charset=utf-8",
50
+ }
51
+ response = requests.request(method=method, url=url, headers=headers, json=body)
52
+
53
+ else:
54
+ headers = {"Authorization": f"Bearer {self.aad_token}"}
55
+ response = requests.request(method=method, url=url, headers=headers, files=files)
56
+
57
+ iteration_count += 1
58
+
59
+ invoke_log_message = _format_invoke_log(response, method, url, body)
60
+
61
+ # Handle long-running operations
62
+ # https://learn.microsoft.com/en-us/rest/api/fabric/core/long-running-operations/get-operation-result
63
+ if (response.status_code == 200 and long_running) or response.status_code == 202:
64
+ url = response.headers.get("Location")
65
+ method = "GET"
66
+ body = "{}"
67
+ response_json = response.json()
68
+
69
+ if long_running:
70
+ status = response_json.get("status")
71
+ if status == "Succeeded":
72
+ long_running = False
73
+ exit_loop = True
74
+ elif status == "Failed":
75
+ response_error = response_json["error"]
76
+ msg = f"Operation failed. Error Code: {response_error['errorCode']}. Error Message: {response_error['message']}"
77
+ raise Exception(msg)
78
+ elif status == "Undefined":
79
+ msg = f"Operation is in an undefined state. Full Body: {response_json}"
80
+ raise Exception(msg)
81
+ else:
82
+ retry_after = float(response.headers.get("Retry-After", 0.5))
83
+ logger.info(f"Operation in progress. Checking again in {retry_after} seconds.")
84
+ time.sleep(retry_after)
85
+ else:
86
+ time.sleep(1)
87
+ long_running = True
88
+
89
+ # Handle successful responses
90
+ elif response.status_code in {200, 201}:
91
+ exit_loop = True
92
+
93
+ # Handle API throttling
94
+ elif response.status_code == 429:
95
+ retry_after = float(response.headers.get("Retry-After", 5)) + 5
96
+ logger.info(f"API Overloaded: Retrying in {retry_after} seconds")
97
+ time.sleep(retry_after)
98
+
99
+ # Handle expired authentication token
100
+ elif (
101
+ response.status_code == 401 and response.headers.get("x-ms-public-api-error-code") == "TokenExpired"
102
+ ):
103
+ logger.info("AAD token expired. Refreshing token.")
104
+ self._refresh_token()
105
+
106
+ # Handle unauthorized access
107
+ elif (
108
+ response.status_code == 401 and response.headers.get("x-ms-public-api-error-code") == "Unauthorized"
109
+ ):
110
+ msg = f"The executing identity is not authorized to call {method} on '{url}'."
111
+ raise Exception(msg)
112
+
113
+ # Handle item name conflicts
114
+ elif (
115
+ response.status_code == 400
116
+ and response.headers.get("x-ms-public-api-error-code") == "ItemDisplayNameAlreadyInUse"
117
+ ):
118
+ if iteration_count <= 6:
119
+ logger.info("Item name is reserved. Retrying in 60 seconds.")
120
+ time.sleep(60)
121
+ else:
122
+ msg = f"Item name still in use after 6 attempts. Description: {response.reason}"
123
+ raise Exception(msg)
124
+
125
+ # Handle scenario where library removed from environment before being removed from repo
126
+ elif response.status_code == 400 and "is not present in the environment." in response.json().get(
127
+ "message", "No message provided"
128
+ ):
129
+ msg = f"Deployment attempted to remove a library that is not present in the environment. Description: {response.json().get('message')}"
130
+ raise Exception(msg)
131
+
132
+ # Handle no environment libraries on GET request
133
+ elif (
134
+ response.status_code == 404
135
+ and response.headers.get("x-ms-public-api-error-code") == "EnvironmentLibrariesNotFound"
136
+ ):
137
+ logger.info("Live environment doesnt have any libraries, continuing")
138
+ exit_loop = True
139
+
140
+ # Handle unsupported principal type
141
+ elif (
142
+ response.status_code == 400
143
+ and response.headers.get("x-ms-public-api-error-code") == "PrincipalTypeNotSupported"
144
+ ):
145
+ msg = f"The executing principal type is not supported to call {method} on '{url}'"
146
+ raise Exception(msg)
147
+
148
+ # Handle unsupported item types
149
+ elif response.status_code == 403 and response.reason == "FeatureNotAvailable":
150
+ msg = f"Item type not supported. Description: {response.reason}"
151
+ raise Exception(msg)
152
+
153
+ # Handle unexpected errors
154
+ else:
155
+ err_msg = (
156
+ f" Message: {response.json()['message']}"
157
+ if "application/json" in (response.headers.get("Content-Type") or "")
158
+ else ""
159
+ )
160
+ msg = f"Unhandled error occurred calling {method} on '{url}'.{err_msg}"
161
+ raise Exception(msg)
162
+
163
+ # Log if reached to end of loop iteration
164
+ if logger.isEnabledFor(logging.DEBUG):
165
+ logger.debug(invoke_log_message)
166
+
167
+ except Exception as e:
168
+ logger.debug(invoke_log_message)
169
+ raise InvokeError(e, logger, invoke_log_message) from e
170
+
171
+ return {
172
+ "header": dict(response.headers),
173
+ "body": (response.json() if "application/json" in response.headers.get("Content-Type") else {}),
174
+ "status_code": response.status_code,
175
+ }
176
+
177
+ def _refresh_token(self):
178
+ """Refreshes the AAD token if empty or expiration has passed"""
179
+ if (
180
+ self.aad_token is None
181
+ or self.aad_token_expiration is None
182
+ or self.aad_token_expiration < datetime.datetime.utcnow()
183
+ ):
184
+ resource_url = "https://api.fabric.microsoft.com/.default"
185
+
186
+ try:
187
+ self.aad_token = self.token_credential.get_token(resource_url).token
188
+ except ClientAuthenticationError as e:
189
+ msg = f"Failed to aquire AAD token. {e}"
190
+ raise TokenError(msg, logger) from e
191
+ except Exception as e:
192
+ msg = f"An unexpected error occurred when generating the AAD token. {e}"
193
+ raise TokenError(msg, logger) from e
194
+
195
+ try:
196
+ decoded_token = _decode_jwt(self.aad_token)
197
+ expiration = decoded_token.get("exp")
198
+ upn = decoded_token.get("upn")
199
+ appid = decoded_token.get("appid")
200
+ oid = decoded_token.get("oid")
201
+
202
+ if expiration:
203
+ self.aad_token_expiration = datetime.datetime.fromtimestamp(expiration)
204
+ else:
205
+ msg = "Token does not contain expiration claim."
206
+ raise TokenError(msg, logger)
207
+
208
+ if upn:
209
+ logger.info(f"Executing as User '{upn}'")
210
+ self.upn_auth = True
211
+ else:
212
+ self.upn_auth = False
213
+ if appid:
214
+ logger.info(f"Executing as Application Id '{appid}'")
215
+ elif oid:
216
+ logger.info(f"Executing as Object Id '{oid}'")
217
+
218
+ except Exception as e:
219
+ msg = f"An unexpected error occurred while decoding the credential token. {e}"
220
+ raise TokenError(msg, logger) from e
221
+
222
+
223
+ def _decode_jwt(token):
224
+ """Decodes a JWT token and returns the payload as a dictionary."""
225
+ try:
226
+ # Split the token into its parts
227
+ parts = token.split(".")
228
+ if len(parts) != 3:
229
+ msg = "The token has an invalid JWT format"
230
+ raise TokenError(msg, logger)
231
+
232
+ # Decode the payload (second part of the token)
233
+ payload = parts[1]
234
+ padding = "=" * (4 - len(payload) % 4)
235
+ payload += padding
236
+ decoded_bytes = base64.urlsafe_b64decode(payload.encode("utf-8"))
237
+ decoded_str = decoded_bytes.decode("utf-8")
238
+ return json.loads(decoded_str)
239
+ except Exception as e:
240
+ msg = f"An unexpected error occurred while decoding the credential token. {e}"
241
+ raise TokenError(msg, logger) from e
242
+
243
+
244
+ def _format_invoke_log(response, method, url, body):
245
+ message = [
246
+ f"\nURL: {url}",
247
+ f"Method: {method}",
248
+ (f"Request Body:\n{json.dumps(body, indent=4)}" if body else "Request Body: None"),
249
+ ]
250
+ if response is not None:
251
+ message.extend([
252
+ f"Response Status: {response.status_code}",
253
+ "Response Headers:",
254
+ json.dumps(dict(response.headers), indent=4),
255
+ "Response Body:",
256
+ (
257
+ json.dumps(response.json(), indent=4)
258
+ if response.headers.get("Content-Type") == "application/json"
259
+ else response.text
260
+ ),
261
+ "",
262
+ ])
263
+
264
+ return "\n".join(message)
@@ -0,0 +1,88 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ import inspect
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import colorlog
10
+
11
+ from fabric_cicd._common import _exceptions
12
+
13
+
14
+ def configure_logger(level: int = logging.INFO) -> None:
15
+ """
16
+ Configure the logger.
17
+
18
+ :param level: The log level to set. Must be one of the standard logging levels.
19
+ """
20
+ # Configure default logging
21
+ logging.basicConfig(
22
+ level=(
23
+ # For non-fabric_cicd packages: INFO if DEBUG, else ERROR
24
+ logging.INFO if level == logging.DEBUG else logging.ERROR
25
+ ),
26
+ format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
27
+ filename="fabric_cicd.error.log",
28
+ filemode="w",
29
+ )
30
+
31
+ # Configure Console Handler
32
+ console_handler = logging.StreamHandler()
33
+ console_handler.setLevel(level)
34
+ console_handler.setFormatter(
35
+ colorlog.ColoredFormatter(
36
+ "%(log_color)s[%(levelname)s] %(asctime)s - %(message)s",
37
+ datefmt="%H:%M:%S",
38
+ log_colors={"DEBUG": "cyan", "INFO": "green", "WARNING": "yellow", "ERROR": "red", "CRITICAL": "bold_red"},
39
+ )
40
+ )
41
+
42
+ # Create a logger that writes to the console and log file
43
+ package_logger = logging.getLogger("fabric_cicd")
44
+ package_logger.setLevel(level)
45
+ package_logger.handlers = []
46
+ package_logger.addHandler(console_handler)
47
+
48
+ # Create a logger that only writes to the console
49
+ console_only_logger = logging.getLogger("console_only")
50
+ console_only_logger.setLevel(level)
51
+ console_only_logger.handlers = []
52
+ console_only_logger.addHandler(console_handler)
53
+ console_only_logger.propagate = False # Prevent logs from being propagated to other loggers
54
+
55
+
56
+ def exception_handler(exception_type, exception, traceback):
57
+ """
58
+ Handle exceptions that are instances of any class from the _common._exceptions module.
59
+
60
+ :param exception_type: The type of the exception.
61
+ :param exception: The exception instance.
62
+ :param traceback: The traceback object.
63
+ """
64
+ # Get all exception classes from the _common._exceptions module
65
+ exception_classes = [cls for _, cls in inspect.getmembers(_exceptions, inspect.isclass)]
66
+
67
+ # Check if the exception is an instance of any class from _common._exceptions
68
+ if any(isinstance(exception, cls) for cls in exception_classes):
69
+ # Log the exception using the logger associated with the exception
70
+ original_logger = exception.logger
71
+
72
+ # Write only the exception message to the console
73
+ logging.getLogger("console_only").error(
74
+ f"{exception!s}\n\nSee {Path('fabric_cicd.error.log').resolve()} for full details."
75
+ )
76
+
77
+ # Write exception and full stack trace to logs but not terminal
78
+ package_logger = logging.getLogger("fabric_cicd")
79
+
80
+ # Clear any existing handlers to prevent writing to console
81
+ additional_info = getattr(exception, "additional_info", None)
82
+ additional_info = "\n\nAdditional Info: \n" + additional_info if additional_info is not None else ""
83
+
84
+ package_logger.handlers = []
85
+ original_logger.exception(f"%s{additional_info}", exception, exc_info=(exception_type, exception, traceback))
86
+ else:
87
+ # If the exception is not from _common._exceptions, use the default exception handler
88
+ sys.__excepthook__(exception_type, exception, traceback)