dt-extensions-sdk 1.2.2__py3-none-any.whl → 1.2.4__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.
Files changed (32) hide show
  1. {dt_extensions_sdk-1.2.2.dist-info → dt_extensions_sdk-1.2.4.dist-info}/METADATA +1 -1
  2. dt_extensions_sdk-1.2.4.dist-info/RECORD +34 -0
  3. {dt_extensions_sdk-1.2.2.dist-info → dt_extensions_sdk-1.2.4.dist-info}/WHEEL +1 -1
  4. {dt_extensions_sdk-1.2.2.dist-info → dt_extensions_sdk-1.2.4.dist-info}/licenses/LICENSE.txt +9 -9
  5. dynatrace_extension/__about__.py +5 -5
  6. dynatrace_extension/__init__.py +27 -27
  7. dynatrace_extension/cli/__init__.py +5 -5
  8. dynatrace_extension/cli/create/__init__.py +1 -1
  9. dynatrace_extension/cli/create/create.py +76 -76
  10. dynatrace_extension/cli/create/extension_template/.gitignore.template +160 -160
  11. dynatrace_extension/cli/create/extension_template/README.md.template +33 -33
  12. dynatrace_extension/cli/create/extension_template/activation.json.template +15 -15
  13. dynatrace_extension/cli/create/extension_template/extension/activationSchema.json.template +118 -118
  14. dynatrace_extension/cli/create/extension_template/extension/extension.yaml.template +17 -17
  15. dynatrace_extension/cli/create/extension_template/extension_name/__main__.py.template +43 -43
  16. dynatrace_extension/cli/create/extension_template/setup.py.template +28 -28
  17. dynatrace_extension/cli/main.py +437 -437
  18. dynatrace_extension/cli/schema.py +129 -129
  19. dynatrace_extension/sdk/__init__.py +3 -3
  20. dynatrace_extension/sdk/activation.py +43 -43
  21. dynatrace_extension/sdk/callback.py +145 -135
  22. dynatrace_extension/sdk/communication.py +483 -483
  23. dynatrace_extension/sdk/event.py +19 -19
  24. dynatrace_extension/sdk/extension.py +1070 -1069
  25. dynatrace_extension/sdk/helper.py +191 -191
  26. dynatrace_extension/sdk/metric.py +118 -118
  27. dynatrace_extension/sdk/runtime.py +67 -67
  28. dynatrace_extension/sdk/snapshot.py +198 -198
  29. dynatrace_extension/sdk/vendor/mureq/LICENSE +13 -13
  30. dynatrace_extension/sdk/vendor/mureq/mureq.py +448 -448
  31. dt_extensions_sdk-1.2.2.dist-info/RECORD +0 -34
  32. {dt_extensions_sdk-1.2.2.dist-info → dt_extensions_sdk-1.2.4.dist-info}/entry_points.txt +0 -0
@@ -1,437 +1,437 @@
1
- import os
2
- import shutil
3
- import stat
4
- import subprocess
5
- import sys
6
- from pathlib import Path
7
- from typing import List, Optional
8
-
9
- import typer
10
- from dtcli.server_api import upload as dt_cli_upload # type: ignore
11
- from dtcli.server_api import validate as dt_cli_validate
12
- from rich.console import Console
13
-
14
- from ..__about__ import __version__
15
- from .create import generate_extension, is_pep8_compliant
16
- from .schema import ExtensionYaml
17
-
18
- app = typer.Typer(pretty_exceptions_show_locals=False, pretty_exceptions_enable=False)
19
- console = Console()
20
-
21
- # if we are not python 3.10.X, exit with an error
22
- if sys.version_info < (3, 10) or sys.version_info >= (3, 11):
23
- console.print(f"Python 3.10.X is required to build extensions, you are using {sys.version_info}", style="bold red")
24
- sys.exit(1)
25
-
26
- CERT_DIR_ENVIRONMENT_VAR = "DT_CERTIFICATES_FOLDER"
27
- CERTIFICATE_DEFAULT_PATH = Path.home() / ".dynatrace" / "certificates"
28
-
29
-
30
- # show version
31
- @app.command()
32
- def version():
33
- """
34
- Show the version of the CLI
35
- """
36
- console.print(f"dt-extensions-sdk version {__version__}", style="bold green")
37
-
38
-
39
- @app.command()
40
- def run(
41
- extension_dir: Path = typer.Argument("."),
42
- activation_config: str = "activation.json",
43
- fast_check: bool = typer.Option(False, "--fastcheck"),
44
- local_ingest: bool = typer.Option(False, "--local-ingest"),
45
- local_ingest_port: int = typer.Option(14499, "--local-ingest-port"),
46
- print_metrics: bool = typer.Option(True),
47
- ):
48
- """
49
- Runs an extension, this is used during development to locally run and test an extension
50
-
51
- :param extension_dir: The directory of the extension, by default this is the current directory
52
- :param activation_config: The activation config file, by default this is activation.json
53
- :param fast_check: If true, run a fastcheck and exits
54
- :param local_ingest: If true, send metrics to localhost:14499 on top of printing them
55
- :param local_ingest_port: The port to send metrics to, by default this is 14499
56
- :param print_metrics: If true, print metrics to the console
57
- """
58
-
59
- # This parses the yaml, which validates it before running
60
- extension_yaml = ExtensionYaml(extension_dir / "extension/extension.yaml")
61
- try:
62
- command = [sys.executable, "-m", extension_yaml.python.runtime.module, "--activationconfig", activation_config]
63
- if fast_check:
64
- command.append("--fastcheck")
65
- if local_ingest:
66
- command.append("--local-ingest")
67
- command.append(f"--local-ingest-port={local_ingest_port}")
68
- if not print_metrics:
69
- command.append("--no-print-metrics")
70
- run_process(command, cwd=extension_dir)
71
- except KeyboardInterrupt:
72
- console.print("\nRun interrupted with a KeyboardInterrupt, stopping", style="bold yellow")
73
-
74
-
75
- @app.command(help="Runs wheel, assemble and sign. Downloads dependencies, creates and signs the extension zip file")
76
- def build(
77
- extension_dir: Path = typer.Argument(Path("."), help="Path to the python extension"),
78
- private_key: Path = typer.Option(
79
- Path(CERTIFICATE_DEFAULT_PATH) / "developer.pem",
80
- "--private-key",
81
- "-k",
82
- help="Path to the dev fused key-certificate",
83
- ),
84
- target_directory: Optional[Path] = typer.Option(None, "--target-directory", "-t"),
85
- extra_platforms: Optional[list[str]] = typer.Option(
86
- None, "--extra-platform", "-e", help="Download wheels for an extra platform"
87
- ),
88
- extra_index_url: Optional[str] = typer.Option(
89
- None, "--extra-index-url", "-i", help="Extra index url to use when downloading dependencies"
90
- ),
91
- find_links: Optional[str] = typer.Option(
92
- None, "--find-links", "-f", help="Extra index url to use when downloading dependencies"
93
- ),
94
- ):
95
- """
96
- Builds and signs an extension using the developer fused key-certificate
97
-
98
- :param extension_dir: The directory of the extension, by default this is the current directory
99
- :param private_key: The path to the developer fused key-certificate, if not specified, we try to locate the file developer.pem under the environment
100
- variable DT_CERTIFICATES_FOLDER
101
- :param target_directory: The directory to put the extension zip file in, if not specified, we put it in the dist
102
- folder
103
- :param extra_platforms: Attempt to also download wheels for an extra platform (e.g. manylinux1_x86_64 or win_amd64)
104
- :param extra_index_url: Extra index url to use when downloading dependencies
105
- :param find_links: Extra index url to use when downloading dependencies
106
- """
107
- console.print(f"Building and signing extension from {extension_dir} to {target_directory}", style="cyan")
108
- if target_directory is None:
109
- target_directory = extension_dir / "dist"
110
- if not target_directory.exists():
111
- target_directory.mkdir()
112
-
113
- console.print("Stage 1 - Download and build dependencies", style="bold blue")
114
- wheel(extension_dir, extra_platforms, extra_index_url, find_links)
115
-
116
- console.print("Stage 2 - Create the extension zip file", style="bold blue")
117
- built_zip = assemble(extension_dir, target_directory)
118
-
119
- console.print("Stage 3 - Sign the extension", style="bold blue")
120
- extension_yaml = ExtensionYaml(Path(extension_dir) / "extension" / "extension.yaml")
121
- output = target_directory / extension_yaml.zip_file_name()
122
- sign(built_zip, private_key, output)
123
-
124
- console.print(f"Stage 4 - Delete {built_zip}", style="bold blue")
125
- built_zip.unlink()
126
-
127
-
128
- @app.command(help="Creates the extension zip file, without signing it yet")
129
- def assemble(
130
- extension_dir: Path = typer.Argument(".", help="Path to the python extension"),
131
- output: Path = typer.Option(None, "--output", "-o"),
132
- force: bool = typer.Option(True, "--force", "-f", help="Force overwriting the output zip file"),
133
- ) -> Path:
134
- """
135
- Creates the extension zip file (not yet signed)
136
-
137
- :param extension_dir: The directory of the extension, by default this is the current directory
138
- :param output: The path to the output zip file, if not specified, we put it in the dist folder
139
- :param force: If true, overwrite the output zip file if it exists
140
- """
141
-
142
- # This checks if the yaml is valid, because it parses it
143
- # Also validates that the schema files are valid and exist
144
- extension_yaml = ExtensionYaml(Path(extension_dir) / "extension" / "extension.yaml")
145
- extension_yaml.validate()
146
-
147
- # Checks that the module name is valid and exists in the filesystem
148
- module_folder = Path(extension_dir) / extension_yaml.python.runtime.module
149
- if not module_folder.exists():
150
- msg = f"Extension module folder {module_folder} not found"
151
- raise FileNotFoundError(msg)
152
-
153
- # This is the zip file that will contain the extension
154
- if output is None:
155
- dist_dir = Path(extension_dir) / "dist"
156
- if not dist_dir.exists():
157
- dist_dir.mkdir()
158
- output = dist_dir / "extension.zip"
159
- elif output.exists() and output.is_dir():
160
- output = output / "extension.zip"
161
-
162
- command = ["dt", "ext", "assemble", "--source", f"{Path(extension_dir) / 'extension'}", "--output", f"{output}"]
163
- if force:
164
- command.append("--force")
165
- run_process(command)
166
- console.print(f"Built the extension zip file to {output}", style="bold green")
167
- return output
168
-
169
-
170
- @app.command(help="Downloads the dependencies of the extension to the lib folder")
171
- def wheel(
172
- extension_dir: Path = typer.Argument(".", help="Path to the python extension"),
173
- extra_platforms: Optional[list[str]] = typer.Option(
174
- None, "--extra-platform", "-e", help="Download wheels for an extra platform"
175
- ),
176
- extra_index_url: Optional[str] = typer.Option(
177
- None, "--extra-index-url", "-i", help="Extra index url to use when downloading dependencies"
178
- ),
179
- find_links: Optional[str] = typer.Option(
180
- None, "--find-links", "-f", help="Extra index url to use when downloading dependencies"
181
- ),
182
- ):
183
- """
184
- Builds the extension and it's dependencies into wheel files
185
- Places these files in the lib folder
186
-
187
- :param extension_dir: The directory of the extension, by default this is the current directory
188
- :param extra_platforms: Attempt to also download wheels for an extra platform (e.g. manylinux1_x86_64 or win_amd64)
189
- :param extra_index_url: Extra index url to use when downloading dependencies
190
- :param find_links: Extra index url to use when downloading dependencies
191
- """
192
- relative_lib_folder_dir = "extension/lib"
193
- lib_folder: Path = extension_dir / relative_lib_folder_dir
194
- _clean_directory(lib_folder)
195
-
196
- console.print(f"Downloading dependencies to {lib_folder}", style="cyan")
197
-
198
- # Downloads the dependencies and places them in the lib folder
199
- command = [sys.executable, "-m", "pip", "wheel", "-w", relative_lib_folder_dir]
200
- if extra_index_url is not None:
201
- command.extend(["--extra-index-url", extra_index_url])
202
- if find_links is not None:
203
- command.extend(["--find-links", find_links])
204
- command.append(".")
205
- run_process(command, cwd=extension_dir)
206
-
207
- if extra_platforms:
208
- for extra_platform in extra_platforms:
209
- console.print(f"Downloading wheels for platform {extra_platform}", style="cyan")
210
- command = [
211
- sys.executable,
212
- "-m",
213
- "pip",
214
- "download",
215
- "-d",
216
- relative_lib_folder_dir,
217
- "--only-binary=:all:",
218
- "--platform",
219
- extra_platform,
220
- ]
221
- if extra_index_url:
222
- command.extend(["--extra-index-url", extra_index_url])
223
- if find_links:
224
- command.extend(["--find-links", find_links])
225
- command.append(".")
226
-
227
- run_process(command, cwd=extension_dir)
228
-
229
- console.print(f"Installed dependencies to {lib_folder}", style="bold green")
230
-
231
-
232
- @app.command()
233
- def sign(
234
- zip_file: Path = typer.Argument(Path("dist/extension.zip"), help="Path to the extension zip file"),
235
- certificate: Path = typer.Option(
236
- Path(CERTIFICATE_DEFAULT_PATH) / "developer.pem",
237
- "--certificate",
238
- "-c",
239
- help="Path to the dev fused key-certificate",
240
- ),
241
- output: Path = typer.Option(None, "--output", "-o"),
242
- force: bool = typer.Option(True, "--force", "-f", help="Force overwriting the output zip file"),
243
- ):
244
- """
245
- Signs the extension zip file using the provided fused key-certificate
246
-
247
- :param zip_file: The path to the extension zip file to sign
248
- :param certificate: The developer fused key-certificate to use for signing
249
- :param output: The path to the output zip file, if not specified, we put it in the dist folder
250
- :param force: If true, overwrite the output zip file if it exists
251
- """
252
- if not certificate:
253
- # If the user doesn't specify a certificate, we try to find it in the default directory
254
- # This directory can be set by the environment variable DT_CERTIFICATES_FOLDER
255
- if CERT_DIR_ENVIRONMENT_VAR not in os.environ:
256
- console.print(
257
- f"{CERT_DIR_ENVIRONMENT_VAR} not found in environment variables. Using {CERTIFICATE_DEFAULT_PATH} instead.",
258
- style="yellow",
259
- )
260
- certificate = _find_certificate(CERTIFICATE_DEFAULT_PATH)
261
- else:
262
- certificate = _find_certificate(Path(certificate))
263
-
264
- combined_cert_and_key = certificate
265
-
266
- if output is None:
267
- output = zip_file.parent / f"signed_{zip_file.name}"
268
-
269
- console.print(f"Signing file {zip_file} to {output} with certificate {certificate}", style="cyan")
270
- command = [
271
- "dt",
272
- "ext",
273
- "sign",
274
- "--src",
275
- f"{zip_file}",
276
- "--output",
277
- f"{output}",
278
- "--key",
279
- f"{combined_cert_and_key}",
280
- ]
281
-
282
- if force:
283
- command.append("--force")
284
- run_process(command)
285
- console.print(f"Created signed extension file {output}", style="bold green")
286
-
287
-
288
- @app.command(help="Upload the extension to a Dynatrace environment")
289
- def upload(
290
- extension_path: Path = typer.Argument(None, help="Path to the extension folder or built zip file"),
291
- tenant_url: str = typer.Option(None, "--tenant-url", "-u", help="Dynatrace tenant URL"),
292
- api_token: str = typer.Option(None, "--api-token", "-t", help="Dynatrace API token"),
293
- validate: bool = typer.Option(None, "--validate", "-v", help="Validate only"),
294
- ):
295
- """
296
- Uploads the extension to a Dynatrace environment
297
-
298
- :param extension_path: The path to the extension folder or built zip file
299
- :param tenant_url: The Dynatrace tenant URL
300
- :param api_token: The Dynatrace API token
301
- :param validate: If true, only validate the extension and exit
302
- """
303
-
304
- zip_file_path = extension_path
305
- if extension_path is None:
306
- extension_path = Path(".")
307
- else:
308
- extension_path = Path(extension_path)
309
- if extension_path.is_dir():
310
- yaml_path = Path(extension_path, "extension", "extension.yaml")
311
- extension_yaml = ExtensionYaml(yaml_path)
312
- zip_file_name = extension_yaml.zip_file_name()
313
- zip_file_path = Path(extension_path, "dist", zip_file_name)
314
-
315
- api_url = tenant_url or os.environ.get("DT_API_URL", "")
316
- api_url = api_url.rstrip("/")
317
-
318
- if not api_url:
319
- console.print("Set the --tenant-url parameter or the DT_API_URL environment variable", style="bold red")
320
- sys.exit(1)
321
-
322
- api_token = api_token or os.environ.get("DT_API_TOKEN", "")
323
- if not api_token:
324
- console.print("Set the --api-token parameter or the DT_API_TOKEN environment variable", style="bold red")
325
- sys.exit(1)
326
-
327
- if validate:
328
- dt_cli_validate(f"{zip_file_path}", api_url, api_token)
329
- else:
330
- dt_cli_upload(f"{zip_file_path}", api_url, api_token)
331
- console.print(f"Extension {zip_file_path} uploaded to {api_url}", style="bold green")
332
-
333
-
334
- @app.command(help="Generate root and developer certificates and key")
335
- def gencerts(
336
- output: Path = typer.Option(CERTIFICATE_DEFAULT_PATH, "--output", "-o", help="Path to the output directory"),
337
- force: bool = typer.Option(False, "--force", "-f", help="Force overwriting the certificates"),
338
- ):
339
- developer_pem = output / "developer.pem"
340
- command_gen_ca = [
341
- "dt",
342
- "ext",
343
- "genca",
344
- "--ca-cert",
345
- f"{output / 'ca.pem'}",
346
- "--ca-key",
347
- f"{output / 'ca.key'}",
348
- "--no-ca-passphrase",
349
- ]
350
-
351
- command_gen_dev_pem = [
352
- "dt",
353
- "ext",
354
- "generate-developer-pem",
355
- "--output",
356
- f"{developer_pem}",
357
- "--name",
358
- "Acme",
359
- "--ca-crt",
360
- f"{output / 'ca.pem'}",
361
- "--ca-key",
362
- f"{output / 'ca.key'}",
363
- ]
364
-
365
- if output.exists():
366
- if developer_pem.exists() and force:
367
- command_gen_ca.append("--force")
368
- developer_pem.chmod(stat.S_IREAD | stat.S_IWRITE)
369
- developer_pem.unlink(missing_ok=True)
370
- elif developer_pem.exists() and not force:
371
- msg = f"Certificates were NOT generated! {developer_pem} already exists. Use --force option to overwrite the certificates"
372
- console.print(msg, style="bold red")
373
- sys.exit(1)
374
- else:
375
- output.mkdir(parents=True)
376
-
377
- run_process(command_gen_ca)
378
- run_process(command_gen_dev_pem)
379
-
380
-
381
- @app.command(help="Creates a new python extension")
382
- def create(extension_name: str, output: Path = typer.Option(None, "--output", "-o")):
383
- """
384
- Creates a new python extension
385
-
386
- :param extension_name: The name of the extension
387
- :param output: The path to the output directory, if not specified, we will use the extension name
388
- """
389
-
390
- if not is_pep8_compliant(extension_name):
391
- msg = f"Extension name {extension_name} is not valid, should be short, all-lowercase and underscores can be used if it improves readability"
392
- raise Exception(msg)
393
-
394
- if output is None:
395
- output = Path.cwd() / extension_name
396
- else:
397
- output = output / extension_name
398
- extension_path = generate_extension(extension_name, output)
399
- console.print(f"Extension created at {extension_path}", style="bold green")
400
-
401
-
402
- def run_process(
403
- command: List[str], cwd: Optional[Path] = None, env: Optional[dict] = None, print_message: Optional[str] = None
404
- ):
405
- friendly_command = " ".join(command)
406
- if print_message is not None:
407
- console.print(print_message, style="cyan")
408
- else:
409
- console.print(f"Running: {friendly_command}", style="cyan")
410
- return subprocess.run(command, cwd=cwd, env=env, check=True) # noqa: S603
411
-
412
-
413
- def _clean_directory(directory: Path):
414
- if directory.exists():
415
- console.print(f"Cleaning {directory}", style="cyan")
416
- shutil.rmtree(directory)
417
-
418
-
419
- def _find_certificate(path: Path) -> Path:
420
- """
421
- Verifies the existence of the file in given path or returns the default file
422
- """
423
-
424
- # If the user specified the path as a directory, we try to find developer.pem in this directory
425
- if path.is_dir():
426
- certificate = path / "developer.pem"
427
- else:
428
- certificate = path
429
-
430
- if not certificate.exists():
431
- msg = f"Certificate {certificate} not found"
432
- raise FileNotFoundError(msg)
433
- return certificate
434
-
435
-
436
- if __name__ == "__main__":
437
- app()
1
+ import os
2
+ import shutil
3
+ import stat
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+
9
+ import typer
10
+ from dtcli.server_api import upload as dt_cli_upload # type: ignore
11
+ from dtcli.server_api import validate as dt_cli_validate
12
+ from rich.console import Console
13
+
14
+ from ..__about__ import __version__
15
+ from .create import generate_extension, is_pep8_compliant
16
+ from .schema import ExtensionYaml
17
+
18
+ app = typer.Typer(pretty_exceptions_show_locals=False, pretty_exceptions_enable=False)
19
+ console = Console()
20
+
21
+ # if we are not python 3.10.X, exit with an error
22
+ if sys.version_info < (3, 10) or sys.version_info >= (3, 11):
23
+ console.print(f"Python 3.10.X is required to build extensions, you are using {sys.version_info}", style="bold red")
24
+ sys.exit(1)
25
+
26
+ CERT_DIR_ENVIRONMENT_VAR = "DT_CERTIFICATES_FOLDER"
27
+ CERTIFICATE_DEFAULT_PATH = Path.home() / ".dynatrace" / "certificates"
28
+
29
+
30
+ # show version
31
+ @app.command()
32
+ def version():
33
+ """
34
+ Show the version of the CLI
35
+ """
36
+ console.print(f"dt-extensions-sdk version {__version__}", style="bold green")
37
+
38
+
39
+ @app.command()
40
+ def run(
41
+ extension_dir: Path = typer.Argument("."),
42
+ activation_config: str = "activation.json",
43
+ fast_check: bool = typer.Option(False, "--fastcheck"),
44
+ local_ingest: bool = typer.Option(False, "--local-ingest"),
45
+ local_ingest_port: int = typer.Option(14499, "--local-ingest-port"),
46
+ print_metrics: bool = typer.Option(True),
47
+ ):
48
+ """
49
+ Runs an extension, this is used during development to locally run and test an extension
50
+
51
+ :param extension_dir: The directory of the extension, by default this is the current directory
52
+ :param activation_config: The activation config file, by default this is activation.json
53
+ :param fast_check: If true, run a fastcheck and exits
54
+ :param local_ingest: If true, send metrics to localhost:14499 on top of printing them
55
+ :param local_ingest_port: The port to send metrics to, by default this is 14499
56
+ :param print_metrics: If true, print metrics to the console
57
+ """
58
+
59
+ # This parses the yaml, which validates it before running
60
+ extension_yaml = ExtensionYaml(extension_dir / "extension/extension.yaml")
61
+ try:
62
+ command = [sys.executable, "-m", extension_yaml.python.runtime.module, "--activationconfig", activation_config]
63
+ if fast_check:
64
+ command.append("--fastcheck")
65
+ if local_ingest:
66
+ command.append("--local-ingest")
67
+ command.append(f"--local-ingest-port={local_ingest_port}")
68
+ if not print_metrics:
69
+ command.append("--no-print-metrics")
70
+ run_process(command, cwd=extension_dir)
71
+ except KeyboardInterrupt:
72
+ console.print("\nRun interrupted with a KeyboardInterrupt, stopping", style="bold yellow")
73
+
74
+
75
+ @app.command(help="Runs wheel, assemble and sign. Downloads dependencies, creates and signs the extension zip file")
76
+ def build(
77
+ extension_dir: Path = typer.Argument(Path("."), help="Path to the python extension"),
78
+ private_key: Path = typer.Option(
79
+ Path(CERTIFICATE_DEFAULT_PATH) / "developer.pem",
80
+ "--private-key",
81
+ "-k",
82
+ help="Path to the dev fused key-certificate",
83
+ ),
84
+ target_directory: Optional[Path] = typer.Option(None, "--target-directory", "-t"),
85
+ extra_platforms: Optional[list[str]] = typer.Option(
86
+ None, "--extra-platform", "-e", help="Download wheels for an extra platform"
87
+ ),
88
+ extra_index_url: Optional[str] = typer.Option(
89
+ None, "--extra-index-url", "-i", help="Extra index url to use when downloading dependencies"
90
+ ),
91
+ find_links: Optional[str] = typer.Option(
92
+ None, "--find-links", "-f", help="Extra index url to use when downloading dependencies"
93
+ ),
94
+ ):
95
+ """
96
+ Builds and signs an extension using the developer fused key-certificate
97
+
98
+ :param extension_dir: The directory of the extension, by default this is the current directory
99
+ :param private_key: The path to the developer fused key-certificate, if not specified, we try to locate the file developer.pem under the environment
100
+ variable DT_CERTIFICATES_FOLDER
101
+ :param target_directory: The directory to put the extension zip file in, if not specified, we put it in the dist
102
+ folder
103
+ :param extra_platforms: Attempt to also download wheels for an extra platform (e.g. manylinux1_x86_64 or win_amd64)
104
+ :param extra_index_url: Extra index url to use when downloading dependencies
105
+ :param find_links: Extra index url to use when downloading dependencies
106
+ """
107
+ console.print(f"Building and signing extension from {extension_dir} to {target_directory}", style="cyan")
108
+ if target_directory is None:
109
+ target_directory = extension_dir / "dist"
110
+ if not target_directory.exists():
111
+ target_directory.mkdir()
112
+
113
+ console.print("Stage 1 - Download and build dependencies", style="bold blue")
114
+ wheel(extension_dir, extra_platforms, extra_index_url, find_links)
115
+
116
+ console.print("Stage 2 - Create the extension zip file", style="bold blue")
117
+ built_zip = assemble(extension_dir, target_directory)
118
+
119
+ console.print("Stage 3 - Sign the extension", style="bold blue")
120
+ extension_yaml = ExtensionYaml(Path(extension_dir) / "extension" / "extension.yaml")
121
+ output = target_directory / extension_yaml.zip_file_name()
122
+ sign(built_zip, private_key, output)
123
+
124
+ console.print(f"Stage 4 - Delete {built_zip}", style="bold blue")
125
+ built_zip.unlink()
126
+
127
+
128
+ @app.command(help="Creates the extension zip file, without signing it yet")
129
+ def assemble(
130
+ extension_dir: Path = typer.Argument(".", help="Path to the python extension"),
131
+ output: Path = typer.Option(None, "--output", "-o"),
132
+ force: bool = typer.Option(True, "--force", "-f", help="Force overwriting the output zip file"),
133
+ ) -> Path:
134
+ """
135
+ Creates the extension zip file (not yet signed)
136
+
137
+ :param extension_dir: The directory of the extension, by default this is the current directory
138
+ :param output: The path to the output zip file, if not specified, we put it in the dist folder
139
+ :param force: If true, overwrite the output zip file if it exists
140
+ """
141
+
142
+ # This checks if the yaml is valid, because it parses it
143
+ # Also validates that the schema files are valid and exist
144
+ extension_yaml = ExtensionYaml(Path(extension_dir) / "extension" / "extension.yaml")
145
+ extension_yaml.validate()
146
+
147
+ # Checks that the module name is valid and exists in the filesystem
148
+ module_folder = Path(extension_dir) / extension_yaml.python.runtime.module
149
+ if not module_folder.exists():
150
+ msg = f"Extension module folder {module_folder} not found"
151
+ raise FileNotFoundError(msg)
152
+
153
+ # This is the zip file that will contain the extension
154
+ if output is None:
155
+ dist_dir = Path(extension_dir) / "dist"
156
+ if not dist_dir.exists():
157
+ dist_dir.mkdir()
158
+ output = dist_dir / "extension.zip"
159
+ elif output.exists() and output.is_dir():
160
+ output = output / "extension.zip"
161
+
162
+ command = ["dt", "ext", "assemble", "--source", f"{Path(extension_dir) / 'extension'}", "--output", f"{output}"]
163
+ if force:
164
+ command.append("--force")
165
+ run_process(command)
166
+ console.print(f"Built the extension zip file to {output}", style="bold green")
167
+ return output
168
+
169
+
170
+ @app.command(help="Downloads the dependencies of the extension to the lib folder")
171
+ def wheel(
172
+ extension_dir: Path = typer.Argument(".", help="Path to the python extension"),
173
+ extra_platforms: Optional[list[str]] = typer.Option(
174
+ None, "--extra-platform", "-e", help="Download wheels for an extra platform"
175
+ ),
176
+ extra_index_url: Optional[str] = typer.Option(
177
+ None, "--extra-index-url", "-i", help="Extra index url to use when downloading dependencies"
178
+ ),
179
+ find_links: Optional[str] = typer.Option(
180
+ None, "--find-links", "-f", help="Extra index url to use when downloading dependencies"
181
+ ),
182
+ ):
183
+ """
184
+ Builds the extension and it's dependencies into wheel files
185
+ Places these files in the lib folder
186
+
187
+ :param extension_dir: The directory of the extension, by default this is the current directory
188
+ :param extra_platforms: Attempt to also download wheels for an extra platform (e.g. manylinux1_x86_64 or win_amd64)
189
+ :param extra_index_url: Extra index url to use when downloading dependencies
190
+ :param find_links: Extra index url to use when downloading dependencies
191
+ """
192
+ relative_lib_folder_dir = "extension/lib"
193
+ lib_folder: Path = extension_dir / relative_lib_folder_dir
194
+ _clean_directory(lib_folder)
195
+
196
+ console.print(f"Downloading dependencies to {lib_folder}", style="cyan")
197
+
198
+ # Downloads the dependencies and places them in the lib folder
199
+ command = [sys.executable, "-m", "pip", "wheel", "-w", relative_lib_folder_dir]
200
+ if extra_index_url is not None:
201
+ command.extend(["--extra-index-url", extra_index_url])
202
+ if find_links is not None:
203
+ command.extend(["--find-links", find_links])
204
+ command.append(".")
205
+ run_process(command, cwd=extension_dir)
206
+
207
+ if extra_platforms:
208
+ for extra_platform in extra_platforms:
209
+ console.print(f"Downloading wheels for platform {extra_platform}", style="cyan")
210
+ command = [
211
+ sys.executable,
212
+ "-m",
213
+ "pip",
214
+ "download",
215
+ "-d",
216
+ relative_lib_folder_dir,
217
+ "--only-binary=:all:",
218
+ "--platform",
219
+ extra_platform,
220
+ ]
221
+ if extra_index_url:
222
+ command.extend(["--extra-index-url", extra_index_url])
223
+ if find_links:
224
+ command.extend(["--find-links", find_links])
225
+ command.append(".")
226
+
227
+ run_process(command, cwd=extension_dir)
228
+
229
+ console.print(f"Installed dependencies to {lib_folder}", style="bold green")
230
+
231
+
232
+ @app.command()
233
+ def sign(
234
+ zip_file: Path = typer.Argument(Path("dist/extension.zip"), help="Path to the extension zip file"),
235
+ certificate: Path = typer.Option(
236
+ Path(CERTIFICATE_DEFAULT_PATH) / "developer.pem",
237
+ "--certificate",
238
+ "-c",
239
+ help="Path to the dev fused key-certificate",
240
+ ),
241
+ output: Path = typer.Option(None, "--output", "-o"),
242
+ force: bool = typer.Option(True, "--force", "-f", help="Force overwriting the output zip file"),
243
+ ):
244
+ """
245
+ Signs the extension zip file using the provided fused key-certificate
246
+
247
+ :param zip_file: The path to the extension zip file to sign
248
+ :param certificate: The developer fused key-certificate to use for signing
249
+ :param output: The path to the output zip file, if not specified, we put it in the dist folder
250
+ :param force: If true, overwrite the output zip file if it exists
251
+ """
252
+ if not certificate:
253
+ # If the user doesn't specify a certificate, we try to find it in the default directory
254
+ # This directory can be set by the environment variable DT_CERTIFICATES_FOLDER
255
+ if CERT_DIR_ENVIRONMENT_VAR not in os.environ:
256
+ console.print(
257
+ f"{CERT_DIR_ENVIRONMENT_VAR} not found in environment variables. Using {CERTIFICATE_DEFAULT_PATH} instead.",
258
+ style="yellow",
259
+ )
260
+ certificate = _find_certificate(CERTIFICATE_DEFAULT_PATH)
261
+ else:
262
+ certificate = _find_certificate(Path(certificate))
263
+
264
+ combined_cert_and_key = certificate
265
+
266
+ if output is None:
267
+ output = zip_file.parent / f"signed_{zip_file.name}"
268
+
269
+ console.print(f"Signing file {zip_file} to {output} with certificate {certificate}", style="cyan")
270
+ command = [
271
+ "dt",
272
+ "ext",
273
+ "sign",
274
+ "--src",
275
+ f"{zip_file}",
276
+ "--output",
277
+ f"{output}",
278
+ "--key",
279
+ f"{combined_cert_and_key}",
280
+ ]
281
+
282
+ if force:
283
+ command.append("--force")
284
+ run_process(command)
285
+ console.print(f"Created signed extension file {output}", style="bold green")
286
+
287
+
288
+ @app.command(help="Upload the extension to a Dynatrace environment")
289
+ def upload(
290
+ extension_path: Path = typer.Argument(None, help="Path to the extension folder or built zip file"),
291
+ tenant_url: str = typer.Option(None, "--tenant-url", "-u", help="Dynatrace tenant URL"),
292
+ api_token: str = typer.Option(None, "--api-token", "-t", help="Dynatrace API token"),
293
+ validate: bool = typer.Option(None, "--validate", "-v", help="Validate only"),
294
+ ):
295
+ """
296
+ Uploads the extension to a Dynatrace environment
297
+
298
+ :param extension_path: The path to the extension folder or built zip file
299
+ :param tenant_url: The Dynatrace tenant URL
300
+ :param api_token: The Dynatrace API token
301
+ :param validate: If true, only validate the extension and exit
302
+ """
303
+
304
+ zip_file_path = extension_path
305
+ if extension_path is None:
306
+ extension_path = Path(".")
307
+ else:
308
+ extension_path = Path(extension_path)
309
+ if extension_path.is_dir():
310
+ yaml_path = Path(extension_path, "extension", "extension.yaml")
311
+ extension_yaml = ExtensionYaml(yaml_path)
312
+ zip_file_name = extension_yaml.zip_file_name()
313
+ zip_file_path = Path(extension_path, "dist", zip_file_name)
314
+
315
+ api_url = tenant_url or os.environ.get("DT_API_URL", "")
316
+ api_url = api_url.rstrip("/")
317
+
318
+ if not api_url:
319
+ console.print("Set the --tenant-url parameter or the DT_API_URL environment variable", style="bold red")
320
+ sys.exit(1)
321
+
322
+ api_token = api_token or os.environ.get("DT_API_TOKEN", "")
323
+ if not api_token:
324
+ console.print("Set the --api-token parameter or the DT_API_TOKEN environment variable", style="bold red")
325
+ sys.exit(1)
326
+
327
+ if validate:
328
+ dt_cli_validate(f"{zip_file_path}", api_url, api_token)
329
+ else:
330
+ dt_cli_upload(f"{zip_file_path}", api_url, api_token)
331
+ console.print(f"Extension {zip_file_path} uploaded to {api_url}", style="bold green")
332
+
333
+
334
+ @app.command(help="Generate root and developer certificates and key")
335
+ def gencerts(
336
+ output: Path = typer.Option(CERTIFICATE_DEFAULT_PATH, "--output", "-o", help="Path to the output directory"),
337
+ force: bool = typer.Option(False, "--force", "-f", help="Force overwriting the certificates"),
338
+ ):
339
+ developer_pem = output / "developer.pem"
340
+ command_gen_ca = [
341
+ "dt",
342
+ "ext",
343
+ "genca",
344
+ "--ca-cert",
345
+ f"{output / 'ca.pem'}",
346
+ "--ca-key",
347
+ f"{output / 'ca.key'}",
348
+ "--no-ca-passphrase",
349
+ ]
350
+
351
+ command_gen_dev_pem = [
352
+ "dt",
353
+ "ext",
354
+ "generate-developer-pem",
355
+ "--output",
356
+ f"{developer_pem}",
357
+ "--name",
358
+ "Acme",
359
+ "--ca-crt",
360
+ f"{output / 'ca.pem'}",
361
+ "--ca-key",
362
+ f"{output / 'ca.key'}",
363
+ ]
364
+
365
+ if output.exists():
366
+ if developer_pem.exists() and force:
367
+ command_gen_ca.append("--force")
368
+ developer_pem.chmod(stat.S_IREAD | stat.S_IWRITE)
369
+ developer_pem.unlink(missing_ok=True)
370
+ elif developer_pem.exists() and not force:
371
+ msg = f"Certificates were NOT generated! {developer_pem} already exists. Use --force option to overwrite the certificates"
372
+ console.print(msg, style="bold red")
373
+ sys.exit(1)
374
+ else:
375
+ output.mkdir(parents=True)
376
+
377
+ run_process(command_gen_ca)
378
+ run_process(command_gen_dev_pem)
379
+
380
+
381
+ @app.command(help="Creates a new python extension")
382
+ def create(extension_name: str, output: Path = typer.Option(None, "--output", "-o")):
383
+ """
384
+ Creates a new python extension
385
+
386
+ :param extension_name: The name of the extension
387
+ :param output: The path to the output directory, if not specified, we will use the extension name
388
+ """
389
+
390
+ if not is_pep8_compliant(extension_name):
391
+ msg = f"Extension name {extension_name} is not valid, should be short, all-lowercase and underscores can be used if it improves readability"
392
+ raise Exception(msg)
393
+
394
+ if output is None:
395
+ output = Path.cwd() / extension_name
396
+ else:
397
+ output = output / extension_name
398
+ extension_path = generate_extension(extension_name, output)
399
+ console.print(f"Extension created at {extension_path}", style="bold green")
400
+
401
+
402
+ def run_process(
403
+ command: List[str], cwd: Optional[Path] = None, env: Optional[dict] = None, print_message: Optional[str] = None
404
+ ):
405
+ friendly_command = " ".join(command)
406
+ if print_message is not None:
407
+ console.print(print_message, style="cyan")
408
+ else:
409
+ console.print(f"Running: {friendly_command}", style="cyan")
410
+ return subprocess.run(command, cwd=cwd, env=env, check=True) # noqa: S603
411
+
412
+
413
+ def _clean_directory(directory: Path):
414
+ if directory.exists():
415
+ console.print(f"Cleaning {directory}", style="cyan")
416
+ shutil.rmtree(directory)
417
+
418
+
419
+ def _find_certificate(path: Path) -> Path:
420
+ """
421
+ Verifies the existence of the file in given path or returns the default file
422
+ """
423
+
424
+ # If the user specified the path as a directory, we try to find developer.pem in this directory
425
+ if path.is_dir():
426
+ certificate = path / "developer.pem"
427
+ else:
428
+ certificate = path
429
+
430
+ if not certificate.exists():
431
+ msg = f"Certificate {certificate} not found"
432
+ raise FileNotFoundError(msg)
433
+ return certificate
434
+
435
+
436
+ if __name__ == "__main__":
437
+ app()