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