polyapi-python 0.3.1.dev0__py3-none-any.whl → 0.3.1.dev2__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.
polyapi/__init__.py CHANGED
@@ -16,8 +16,8 @@ if len(sys.argv) > 1 and sys.argv[1] not in CLI_COMMANDS:
16
16
 
17
17
 
18
18
  polyCustom: Dict[str, Any] = {
19
- "executionId": None,
20
- "executionApiKey": None,
21
- "responseStatusCode": 200,
22
- "responseContentType": None,
19
+ "executionId": None,
20
+ "executionApiKey": None,
21
+ "responseStatusCode": 200,
22
+ "responseContentType": None,
23
23
  }
polyapi/cli.py CHANGED
@@ -1,72 +1,186 @@
1
+ import os
1
2
  import argparse
2
3
 
3
- from polyapi.utils import print_green
4
+ from polyapi.utils import print_green, print_red
4
5
 
5
6
  from .config import initialize_config, set_api_key_and_url
6
7
  from .generate import generate, clear
7
8
  from .function_cli import function_add_or_update, function_execute
8
9
  from .rendered_spec import get_and_update_rendered_spec
10
+ from .prepare import prepare_deployables
11
+ from .sync import sync_deployables
9
12
 
10
13
 
11
14
  CLI_COMMANDS = ["setup", "generate", "function", "clear", "help", "update_rendered_spec"]
12
15
 
13
- CLIENT_DESC = """Commands
14
- python -m polyapi setup Setup your Poly connection
15
- python -m polyapi generate Generates Poly library
16
- python -m polyapi function <command> Manages functions
17
- python -m polyapi clear Clear current generated Poly library
18
- """
19
-
20
-
21
- def execute_from_cli() -> None:
16
+ def execute_from_cli():
17
+ # First we setup all our argument parsing logic
18
+ # Then we parse the arguments (waaay at the bottom)
22
19
  parser = argparse.ArgumentParser(
23
- prog="python -m polyapi", description=CLIENT_DESC, formatter_class=argparse.RawTextHelpFormatter
20
+ prog="python -m polyapi",
21
+ description="Manage your Poly API configurations and functions",
22
+ formatter_class=argparse.RawTextHelpFormatter
24
23
  )
25
- parser.add_argument("--context", required=False, default="")
26
- parser.add_argument("--description", required=False, default="")
27
- parser.add_argument("--client", action="store_true", help="Pass --client when adding function to add a client function.")
28
- parser.add_argument("--server", action="store_true", help="Pass --server when adding function to add a server function.")
29
- parser.add_argument("--logs", action="store_true", help="Pass --logs when adding function if you want to store and see the function logs.")
30
- parser.add_argument("--skip-generate", action="store_true", help="Pass --skip-generate to skip generating the library after adding a function.")
31
- parser.add_argument("--execution-api-key", required=False, default="", help="API key for execution (for server functions only).")
32
- parser.add_argument("command", choices=CLI_COMMANDS)
33
- parser.add_argument("subcommands", nargs="*")
34
- args = parser.parse_args()
35
- command = args.command
36
-
37
- if command == "help":
38
- parser.print_help()
39
- elif command == "generate":
24
+
25
+ subparsers = parser.add_subparsers(help="Available commands")
26
+
27
+ ###########################################################################
28
+ # Setup command
29
+ setup_parser = subparsers.add_parser("setup", help="Setup your Poly connection")
30
+ setup_parser.add_argument("api_key", nargs="?", help="API key for Poly API")
31
+ setup_parser.add_argument("url", nargs="?", help="URL for the Poly API")
32
+
33
+ def setup(args):
34
+ if args.api_key and args.url:
35
+ set_api_key_and_url(args.url, args.api_key)
36
+ else:
37
+ initialize_config(force=True)
38
+ generate()
39
+
40
+ setup_parser.set_defaults(command=setup)
41
+
42
+
43
+ ###########################################################################
44
+ # Generate command
45
+ generate_parser = subparsers.add_parser("generate", help="Generates Poly library")
46
+
47
+ def generate_command(args):
48
+ initialize_config()
40
49
  print("Generating Poly functions...", end="")
41
50
  generate()
42
51
  print_green("DONE")
43
- elif command == "setup" and len(args.subcommands) == 2:
44
- set_api_key_and_url(args.subcommands[1], args.subcommands[0])
45
- elif command == "setup":
46
- initialize_config(force=True)
47
- generate()
48
- elif command == "update_rendered_spec":
49
- assert len(args.subcommands) == 1
50
- updated = get_and_update_rendered_spec(args.subcommands[0])
52
+
53
+ generate_parser.set_defaults(command=generate_command)
54
+
55
+
56
+ ###########################################################################
57
+ # Function commands
58
+ fn_parser = subparsers.add_parser("function", help="Manage and execute functions")
59
+ fn_subparsers = fn_parser.add_subparsers(help="Available commands")
60
+
61
+ # Function - Add command
62
+ fn_add_parser = fn_subparsers.add_parser("add", help="Add or update the function")
63
+ fn_add_parser.add_argument("name", help="Name of the function")
64
+ fn_add_parser.add_argument("file", help="Path to the function file")
65
+ fn_add_parser.add_argument("--context", required=False, default="", help="Context of the function")
66
+ fn_add_parser.add_argument("--description", required=False, default="", help="Description of the function")
67
+ fn_add_parser.add_argument("--server", action="store_true", help="Marks the function as a server function")
68
+ fn_add_parser.add_argument("--client", action="store_true", help="Marks the function as a client function")
69
+ fn_add_parser.add_argument("--logs", choices=["enabled", "disabled"], default="disabled", help="Enable or disable logs for the function.")
70
+ fn_add_parser.add_argument("--execution-api-key", required=False, default="", help="API key for execution (for server functions only).")
71
+ fn_add_parser.add_argument("--disable-ai", "--skip-generate", action="store_true", help="Pass --disable-ai skip AI generation of missing descriptions")
72
+
73
+ def add_function(args):
74
+ initialize_config()
75
+ logs_enabled = args.logs == "enabled"
76
+ err = ""
77
+ if args.server and args.client:
78
+ err = "Specify either `--server` or `--client`. Found both."
79
+ elif not args.server and not args.client:
80
+ err = "You must specify `--server` or `--client`."
81
+ elif logs_enabled and not args.server:
82
+ err = "Option `logs` is only for server functions (--server)."
83
+
84
+ if err:
85
+ print_red("ERROR")
86
+ print(err)
87
+ exit(1)
88
+
89
+ function_add_or_update(
90
+ context=args.context,
91
+ description=args.description,
92
+ client=args.client,
93
+ server=args.server,
94
+ logs_enabled=logs_enabled,
95
+ generate=not args.disable_ai,
96
+ execution_api_key=args.execution_api_key
97
+ )
98
+
99
+ fn_add_parser.set_defaults(command=add_function)
100
+
101
+
102
+ # Function - Execute command
103
+ fn_exec_parser = fn_subparsers.add_parser("execute", help="Execute a function with the provided arguments")
104
+ fn_exec_parser.add_argument("name", help="Name of the function")
105
+ fn_exec_parser.add_argument("args", nargs="*", help="Arguments for the function")
106
+ fn_exec_parser.add_argument("--context", required=False, default="", help="Context of the function")
107
+
108
+ def execute_function(args):
109
+ initialize_config()
110
+ print(function_execute(args.context, args.name, args.args))
111
+
112
+ fn_exec_parser.set_defaults(command=execute_function)
113
+
114
+
115
+ ###########################################################################
116
+ # Clear command
117
+ clear_parser = subparsers.add_parser("clear", help="Clear current generated Poly library")
118
+
119
+ def clear_command(_):
120
+ print("Clearing the generated library...")
121
+ clear()
122
+
123
+ clear_parser.set_defaults(command=clear_command)
124
+
125
+
126
+ ###########################################################################
127
+ # Update rendered spec command
128
+ update_spec_parser = subparsers.add_parser("update_rendered_spec", help="Update the rendered spec file")
129
+ update_spec_parser.add_argument("spec", help="Specification file to update")
130
+
131
+ def update_rendered_spec(args):
132
+ updated = get_and_update_rendered_spec(args.spec)
51
133
  if updated:
52
134
  print("Updated rendered spec!")
53
135
  else:
54
136
  print("Failed to update rendered spec!")
55
137
  exit(1)
56
- elif command == "clear":
57
- print("Clearing the generated library...")
58
- clear()
59
- elif command == "function":
60
- if args.subcommands[0] == "execute":
61
- print(function_execute(args.context, args.subcommands))
62
- else:
63
- function_add_or_update(
64
- context=args.context,
65
- description=args.description,
66
- client=args.client,
67
- server=args.server,
68
- logs_enabled=args.logs,
69
- subcommands=args.subcommands,
70
- generate=not args.skip_generate,
71
- execution_api_key=args.execution_api_key
72
- )
138
+
139
+ update_spec_parser.set_defaults(command=update_rendered_spec)
140
+
141
+
142
+ ###########################################################################
143
+ # Prepare command
144
+ prepare_parser = subparsers.add_parser('prepare', help="Find and prepare all Poly deployables")
145
+ prepare_parser.add_argument("--lazy", action="store_true", help="Skip prepare work if the cache is up to date. (Relies on `git`)")
146
+ prepare_parser.add_argument("--disable-docs", action="store_true", help="Don't write any docstrings into the deployable files.")
147
+ prepare_parser.add_argument("--disable-ai", action="store_true", help="Don't use AI to fill in any missing descriptions.")
148
+
149
+ def prepare(args):
150
+ initialize_config()
151
+ disable_ai = args.disable_ai or bool(os.getenv("DISABLE_AI"))
152
+ prepare_deployables(lazy=args.lazy, disable_docs=args.disable_docs, disable_ai=disable_ai)
153
+
154
+ prepare_parser.set_defaults(command=prepare)
155
+
156
+
157
+ ###########################################################################
158
+ # Sync command
159
+ sync_parser = subparsers.add_parser("sync", help="Find and sync all Poly deployables")
160
+ sync_parser.add_argument("--dry-run", action="store_true", help="Run through sync steps with logging but don't make any changes.")
161
+
162
+ def sync(args):
163
+ initialize_config()
164
+ prepare_deployables(lazy=True, disable_docs=True, disable_ai=True)
165
+ if args.dry_run:
166
+ print("Running dry-run of sync...")
167
+ sync_deployables(dry_run=args.dry_run)
168
+ print("Poly deployments synced.")
169
+
170
+ sync_parser.set_defaults(command=sync)
171
+
172
+ ###########################################################################
173
+ # _------. #
174
+ # / , \_ __ __ _ ________ #
175
+ # / / /{}\ |o\_ / / ___ / /( )_____ / ____/ /_ __ #
176
+ # / \ `--' /-' \ / / / _ \/ __/// ___/ / /_ / / / / / #
177
+ # | \ \ | / /___/ __/ /_ (__ ) / __/ / / /_/ / #
178
+ # | |`-, | /_____/\___/\__/ /____/ /_/ /_/\__, / #
179
+ # / /__/)/ /____/ #
180
+ # / | #
181
+ ###########################################################################
182
+ parsed_args = parser.parse_args()
183
+ if hasattr(parsed_args, "command"):
184
+ parsed_args.command(parsed_args)
185
+ else:
186
+ parser.print_help()
polyapi/deployables.py ADDED
@@ -0,0 +1,296 @@
1
+ import os
2
+ import subprocess
3
+ import json
4
+ import hashlib
5
+ from pathlib import Path
6
+ from typing import TypedDict, List, Dict, Tuple, Optional, Any
7
+ from subprocess import check_output, CalledProcessError
8
+
9
+
10
+ # Constants
11
+ CACHE_VERSION_FILE = "./poly/deployments_revision"
12
+ CACHE_DIR = Path("./poly/deployables")
13
+
14
+
15
+ class DeployableTypes(str):
16
+ pass
17
+
18
+ class DeployableTypeNames(str):
19
+ pass
20
+
21
+ class Deployment(TypedDict):
22
+ context: str
23
+ name: str
24
+ type: DeployableTypes
25
+ instance: str
26
+ id: str
27
+ deployed: str
28
+ fileRevision: str
29
+
30
+ class ParsedDeployableConfig(TypedDict):
31
+ context: str
32
+ name: str
33
+ type: DeployableTypes
34
+ disableAi: Optional[bool]
35
+ config: Dict[str, Any]
36
+
37
+ class DeployableFunctionParamBase(TypedDict):
38
+ type: str
39
+ typeSchema: Optional[Dict[str, Any]]
40
+ description: str
41
+
42
+ class DeployableFunctionParam(DeployableFunctionParamBase):
43
+ name: str
44
+
45
+ class DeployableFunctionTypes(TypedDict):
46
+ description: str
47
+ params: List[DeployableFunctionParam]
48
+ returns: DeployableFunctionParamBase
49
+
50
+ class DeployableRecord(ParsedDeployableConfig, total=False):
51
+ gitRevision: str
52
+ fileRevision: str
53
+ file: str
54
+ types: DeployableFunctionTypes
55
+ typeSchemas: Dict[str, Any]
56
+ dependencies: List[str]
57
+ deployments: List[Deployment]
58
+ deploymentCommentRanges: List[Tuple[int, int]]
59
+ docStartIndex: int
60
+ docEndIndex: int
61
+ dirty: Optional[bool]
62
+
63
+ class SyncDeployment(TypedDict, total=False):
64
+ context: str
65
+ name: str
66
+ description: str
67
+ type: str # This should be an enumeration or a predefined set of strings if you have known types.
68
+ fileRevision: str
69
+ file: str
70
+ typeSchemas: Dict[str, any]
71
+ dependencies: List[str]
72
+ config: Dict[str, any]
73
+ instance: str
74
+ id: Optional[str] = None
75
+ deployed: Optional[str] = None
76
+
77
+ DeployableTypeEntries: List[Tuple[DeployableTypeNames, DeployableTypes]] = [
78
+ ("PolyServerFunction", "server-function"),
79
+ ("PolyClientFunction", "client-function"),
80
+ ]
81
+
82
+ DeployableTypeToName: Dict[DeployableTypeNames, DeployableTypes] = {name: type for name, type in DeployableTypeEntries}
83
+
84
+ def prepare_deployable_directory() -> None:
85
+ Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
86
+
87
+ def load_deployable_records() -> List[DeployableRecord]:
88
+ return [read_json_file(CACHE_DIR / name) for name in os.listdir(CACHE_DIR) if name.endswith(".json")]
89
+
90
+ def save_deployable_records(records: List[DeployableRecord]) -> None:
91
+ for record in records:
92
+ write_json_file(Path(CACHE_DIR) / f'{record["context"]}.{record["name"]}.json', record)
93
+
94
+ def remove_deployable_records(records: List[DeployableRecord]) -> None:
95
+ for record in records:
96
+ os.remove(Path(CACHE_DIR) / f'{record["context"]}.{record["name"]}.json')
97
+
98
+ def read_json_file(path: Path) -> Any:
99
+ with open(path, "r", encoding="utf-8") as file:
100
+ return json.load(file)
101
+
102
+ def write_json_file(path: Path, contents: Any) -> None:
103
+ with open(path, "w", encoding="utf-8") as file:
104
+ json.dump(contents, file, indent=2)
105
+
106
+ class PolyDeployConfig(TypedDict):
107
+ type_names: List[str]
108
+ include_dirs: List[str]
109
+ include_files_or_extensions: List[str]
110
+ exclude_dirs: List[str]
111
+
112
+ def get_all_deployable_files_windows(config: PolyDeployConfig) -> List[str]:
113
+ # Constructing the Windows command using dir and findstr
114
+ include_pattern = " ".join(f"*.{f}" if "." in f else f"*.{f}" for f in config["include_files_or_extensions"]) or "*"
115
+ exclude_pattern = '|'.join(config["exclude_dirs"])
116
+ pattern = '|'.join(f"polyConfig: {name}" for name in config["type_names"]) or 'polyConfig'
117
+
118
+ exclude_command = f" | findstr /V /I \"{exclude_pattern}\"" if exclude_pattern else ''
119
+ search_command = f" | findstr /M /I /F:/ /C:\"{pattern}\""
120
+
121
+ result = []
122
+ for dir_path in config["include_dirs"]:
123
+ dir_command = f"dir /S /P /B {include_pattern} {dir_path}"
124
+ full_command = f"{dir_command}{exclude_command}{search_command}"
125
+ try:
126
+ output = subprocess.check_output(full_command, shell=True, text=True)
127
+ result.extend(output.strip().split('\r\n'))
128
+ except subprocess.CalledProcessError:
129
+ pass
130
+ return result
131
+
132
+ def get_all_deployable_files_linux(config: PolyDeployConfig) -> List[str]:
133
+ # Constructing the Linux grep command
134
+ include = " ".join(f'--include={f if "." in f else f"*.{f}"}' for f in config["include_files_or_extensions"])
135
+ exclude_dir = " ".join(f"--exclude-dir={dir}" for dir in config["exclude_dirs"])
136
+
137
+ search_path = " ".join(config["include_dirs"]) or "."
138
+ patterns = " ".join(f"-e 'polyConfig: {name}'" for name in config["type_names"]) or "-e 'polyConfig'"
139
+ grep_command = f'grep {include} {exclude_dir} -Rl {patterns} {search_path}'
140
+
141
+ try:
142
+ output = subprocess.check_output(grep_command, shell=True, text=True)
143
+ return output.strip().split('\n')
144
+ except subprocess.CalledProcessError:
145
+ return []
146
+
147
+ def get_all_deployable_files(config: PolyDeployConfig) -> List[str]:
148
+ # Setting default values if not provided
149
+ if not config.get("type_names"):
150
+ config["type_names"] = [entry[0] for entry in DeployableTypeEntries] # Assuming DeployableTypeEntries is defined elsewhere
151
+ if not config.get("include_dirs"):
152
+ config["include_dirs"] = ["."]
153
+ if not config.get("include_files_or_extensions"):
154
+ config["include_files_or_extensions"] = ["py"]
155
+ if not config.get("exclude_dirs"):
156
+ config["exclude_dirs"] = ["poly", "node_modules", "dist", "build", "output", ".vscode", ".poly", ".github", ".husky", ".yarn"]
157
+
158
+ is_windows = os.name == "nt"
159
+ if is_windows:
160
+ return get_all_deployable_files_windows(config)
161
+ else:
162
+ return get_all_deployable_files_linux(config)
163
+
164
+ def get_deployable_file_revision(file_contents: str) -> str:
165
+ # Remove leading single-line comments and hash the remaining contents
166
+ file_contents = "\n".join(line for line in file_contents.split("\n") if not line.strip().startswith("#"))
167
+ return hashlib.sha256(file_contents.encode('utf-8')).hexdigest()[:7]
168
+
169
+ def get_git_revision(branch_or_tag: str = "HEAD") -> str:
170
+ try:
171
+ return check_output(["git", "rev-parse", "--short", branch_or_tag], text=True).strip()
172
+ except CalledProcessError:
173
+ # Return a random 7-character hash as a fallback
174
+ return "".join(format(ord(c), 'x') for c in os.urandom(4))[:7]
175
+
176
+ def get_cache_deployments_revision() -> str:
177
+ """Retrieve the cache deployments revision from a file."""
178
+ try:
179
+ with open(CACHE_VERSION_FILE, 'r', encoding='utf-8') as file:
180
+ return file.read().strip()
181
+ except FileNotFoundError:
182
+ return ''
183
+
184
+ def write_cache_revision(git_revision: Optional[str] = None) -> None:
185
+ if git_revision is None:
186
+ git_revision = get_git_revision()
187
+ with open(CACHE_VERSION_FILE, 'w', encoding='utf-8') as file:
188
+ file.write(git_revision)
189
+
190
+ def is_cache_up_to_date() -> bool:
191
+ if not Path(CACHE_VERSION_FILE).exists():
192
+ return False
193
+ with open(CACHE_VERSION_FILE, 'r', encoding='utf-8') as file:
194
+ cached_revision = file.read().strip()
195
+ git_revision = get_git_revision()
196
+ return cached_revision == git_revision
197
+
198
+ def is_cache_up_to_date() -> bool:
199
+ """Check if the cached revision matches the current Git revision."""
200
+ cached_revision = get_cache_deployments_revision()
201
+ git_revision = get_git_revision() # This function needs to be defined or imported
202
+ return cached_revision == git_revision
203
+
204
+ def write_deploy_comments(deployments: List[Dict]) -> str:
205
+ """Generate a string of deployment comments for each deployment."""
206
+ canopy_path = 'polyui/collections' if 'localhost' in os.getenv('POLY_API_BASE_URL', '') else 'canopy/polyui/collections'
207
+ comments = []
208
+ for d in deployments:
209
+ instance_url = d['instance'].replace(':8000', ':3000') if d['instance'].endswith(':8000') else d['instance']
210
+ comment = f"# Poly deployed @ {d['deployed']} - {d['context']}.{d['name']} - {instance_url}/{canopy_path}/{d['type']}s/{d['id']} - {d['fileRevision']}"
211
+ comments.append(comment)
212
+ return '\n'.join(comments)
213
+
214
+ def print_docstring_function_comment(description: str, args: list, returns: dict) -> str:
215
+ docstring = f'"""{description}\n\n'
216
+ if args:
217
+ docstring += ' Args:\n'
218
+ for arg in args:
219
+ name = arg.get('name')
220
+ arg_type = arg.get('type', '')
221
+ desc = arg.get('description', '')
222
+ if arg_type:
223
+ docstring += f' {name} ({arg_type}): {desc}\n'
224
+ else:
225
+ docstring += f' {name}: {desc}\n'
226
+
227
+ return_type = returns.get('type', '')
228
+ return_description = returns.get('description', '')
229
+ if return_type:
230
+ docstring += f'\n Returns:\n {return_type}: {return_description}\n'
231
+ else:
232
+ docstring += f'\n Returns:\n {return_description}\n'
233
+
234
+ docstring += ' """'
235
+ return docstring
236
+
237
+
238
+ def update_deployment_comments(file_content: str, deployable: dict) -> str:
239
+ """
240
+ Remove old deployment comments based on the provided ranges and add new ones.
241
+ """
242
+ for range in reversed(deployable['deploymentCommentRanges']):
243
+ file_content = file_content[:range[0]] + file_content[range[1]:]
244
+ if deployable['deployments']:
245
+ deployment_comments = write_deploy_comments(deployable['deployments'])
246
+ deployable['deploymentCommentRanges'] = [(0, len(deployment_comments) + 1)]
247
+ file_content = f"{deployment_comments}\n{file_content}"
248
+ return file_content
249
+
250
+ def update_deployable_function_comments(file_content: str, deployable: dict, disable_docs: bool = False) -> str:
251
+ """
252
+ Update the docstring in the file content based on the deployable's documentation data.
253
+ """
254
+ if not disable_docs:
255
+ docstring = print_docstring_function_comment(
256
+ deployable['types']['description'],
257
+ deployable['types']['params'],
258
+ deployable['types']['returns']
259
+ )
260
+ if deployable["docStartIndex"] == deployable["docEndIndex"]:
261
+ # Function doesn't yet have any docstrings so we need to add additional whitespace
262
+ docstring = " " + docstring + "\n"
263
+
264
+ return f"{file_content[:deployable['docStartIndex']]}{docstring}{file_content[deployable['docEndIndex']:]}"
265
+ return file_content
266
+
267
+ def write_updated_deployable(deployable: dict, disable_docs: bool = False) -> dict:
268
+ """
269
+ Read the deployable's file, update its comments and docstring, and write back to the file.
270
+ """
271
+ with open(deployable['file'], 'r', encoding='utf-8') as file:
272
+ file_contents = file.read()
273
+
274
+ if deployable['type'] in ['client-function', 'server-function']:
275
+ file_contents = update_deployable_function_comments(file_contents, deployable, disable_docs)
276
+ else:
277
+ raise ValueError(f"Unsupported deployable type: '{deployable['type']}'")
278
+
279
+ file_contents = update_deployment_comments(file_contents, deployable)
280
+
281
+ with open(deployable['file'], 'w', encoding='utf-8') as file:
282
+ file.write(file_contents)
283
+
284
+ deployable['fileRevision'] = get_deployable_file_revision(file_contents)
285
+ return deployable
286
+
287
+ def write_deploy_comments(deployments: list) -> str:
288
+ """
289
+ Generate deployment comments for each deployment record.
290
+ """
291
+ canopy_path = 'polyui/collections' if 'localhost' in os.getenv('POLY_API_BASE_URL', '') else 'canopy/polyui/collections'
292
+ comments = []
293
+ for d in deployments:
294
+ instance_url = d['instance'].replace(':8000', ':3000') if d['instance'].endswith(':8000') else d['instance']
295
+ comments.append(f"# Poly deployed @ {d['deployed']} - {d['context']}.{d['name']} - {instance_url}/{canopy_path}/{d['type']}s/{d['id']} - {d['fileRevision']}")
296
+ return "\n".join(comments)