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