├── .gitignore
├── Default.sublime-keymap
├── Main.sublime-menu
├── README.md
├── awslambda.py
├── dependencies.json
└── tox.ini
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 |
27 | # PyInstaller
28 | # Usually these files are written by a python script from a template
29 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
30 | *.manifest
31 | *.spec
32 |
33 | # Installer logs
34 | pip-log.txt
35 | pip-delete-this-directory.txt
36 |
37 | # Unit test / coverage reports
38 | htmlcov/
39 | .tox/
40 | .coverage
41 | .coverage.*
42 | .cache
43 | nosetests.xml
44 | coverage.xml
45 | *,cover
46 | .hypothesis/
47 |
48 | # Translations
49 | *.mo
50 | *.pot
51 |
52 | # Django stuff:
53 | *.log
54 |
55 | # Sphinx documentation
56 | docs/_build/
57 |
58 | # PyBuilder
59 | target/
60 |
61 | #Ipython Notebook
62 | .ipynb_checkpoints
63 |
--------------------------------------------------------------------------------
/Default.sublime-keymap:
--------------------------------------------------------------------------------
1 | [
2 | { "keys": ["super+shift+o"], "command": "select_edit_function" },
3 | { "keys": ["super+option+i"], "command": "invoke_function" }
4 | ]
--------------------------------------------------------------------------------
/Main.sublime-menu:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id" : "awslambda",
4 | "caption" : "λ",
5 | "children":
6 | [
7 | {
8 | "caption" : "Select Profile...",
9 | "command" : "select_profile"
10 | },
11 | {
12 | "caption" : "Edit Function...",
13 | "command" : "select_edit_function"
14 | },
15 | {
16 | "caption" : "Invoke Function...",
17 | "command" : "invoke_function"
18 | },
19 | {
20 | "caption" : "Function Info...",
21 | "command" : "select_get_function_info"
22 | },
23 | {
24 | "caption" : "Install Dependency...",
25 | "command" : "install_dependency"
26 | }
27 | ]
28 | }
29 | ]
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Sublime Text 3 plugin for editing AWS Lambda function sources easily.
2 |
3 | ## Features:
4 | * Supports multiple API key profiles
5 | * Automatically zips and uploads new function code on buffer save
6 | * Can easily fetch and install PyPI package dependencies
7 | * Invoke function directly from inside Sublime and view all output
8 |
9 | # Setup
10 | To use this plugin you will need to configure AWS with your access key ID and secret.
11 |
12 | ### AWS CLI Credentials
13 | If you use the AWS command-line interface [you can run `aws configure` to set up your credentials](http://boto3.readthedocs.io/en/latest/guide/configuration.html).
14 | They will be stored in `~/.aws/credentials`.
15 |
16 | ### Boto
17 | [Or you can configure boto](https://pypi.python.org/pypi/boto3/), the official AWS python client library.
18 | Create a file `~/.boto` with your key and secret:
19 | ```
20 | [Credentials]
21 | aws_access_key_id = AKNGOINAGBQOWGQNW
22 | aws_secret_access_key = GEIOWGNQAVIONGhg10g08GOAG/GAing2eingAn
23 | ```
24 |
25 | # Installing The Plugin
26 |
27 | ### Sublime Package Manager
28 | * You must [install the sublime package manager](https://packagecontrol.io/installation) if you don't have it already.
29 | * Select "Install Package" from the command palette and select "AWS Lambda"
30 |
31 | ### Video Instructions
32 | Here's a short video showing how to install sublime package manager and the AWS Lambda plugin:
33 |
36 |
37 |
38 | # Demo Video
39 | ### See it in Action!
40 |
43 |
--------------------------------------------------------------------------------
/awslambda.py:
--------------------------------------------------------------------------------
1 | """Plugin for editing the source of a Lambda in Amazon Web Services.
2 |
3 | Configuration: configure your access key and default region
4 | as shown on https://pypi.python.org/pypi/boto3/1.2.3
5 |
6 | Required IAM roles:
7 | lambda:ListFunctions,
8 | lambda:UpdateFunctionCode,
9 | lambda:GetFunction
10 | """
11 |
12 | import sublime
13 | import sublime_plugin
14 | import boto3
15 | import botocore
16 | import requests
17 | import subprocess
18 | import tempfile
19 | import os
20 | import json
21 | import re
22 | import zipfile
23 | import io
24 | import pprint
25 | from contextlib import contextmanager
26 | from base64 import b64decode
27 |
28 | INFO_FILE_NAME = ".sublime-lambda-info"
29 | SETTINGS_PATH = "awslambda"
30 | DEBUG = False
31 |
32 |
33 | def _dbg(*msgs):
34 | if DEBUG:
35 | print(msgs)
36 |
37 |
38 | @contextmanager
39 | def cd(newdir):
40 | """Change to a directory, change back when context exits."""
41 | prevdir = os.getcwd()
42 | os.chdir(os.path.expanduser(newdir))
43 | try:
44 | yield
45 | finally:
46 | os.chdir(prevdir)
47 |
48 |
49 | class AWSClient():
50 | """Common AWS methods for all AWS-based commands."""
51 |
52 | def get_aws_client(self, client_name):
53 | """Return a boto3 client with our session."""
54 | session = self.get_aws_session()
55 | client = None
56 | try:
57 | client = session.client(client_name)
58 | except botocore.exceptions.NoRegionError:
59 | sublime.error_message("A region must be specified in your configuration.")
60 | return client
61 |
62 | def get_aws_session(self):
63 | """Custom AWS low-level session."""
64 | if '_aws_session' in globals():
65 | _dbg("_aws_session exists")
66 | return globals()['_aws_session']
67 | # load profile from settings
68 | profile_name = self.get_profile_name()
69 | if profile_name not in self.get_available_profiles():
70 | # this profile name appears to not exist
71 | _dbg("Got bogus AWS profile name {}, resetting...".format(profile_name))
72 | profile_name = None
73 | session = boto3.session.Session(profile_name=profile_name)
74 | globals()['_aws_session'] = session
75 | return session
76 |
77 | def get_available_profiles(self):
78 | """Return different configuration profiles available for AWS.
79 |
80 | c.f. https://github.com/boto/boto3/issues/704#issuecomment-231459948
81 | """
82 | sess = boto3.session.Session(profile_name=None)
83 | if not sess:
84 | return []
85 | if not hasattr(sess, 'available_profiles'):
86 | # old boto :/
87 | return sess._session.available_profiles
88 | return sess.available_profiles()
89 |
90 | def get_profile_name(self):
91 | """Get selected profile name."""
92 | return self._settings().get("profile_name")
93 |
94 | def test_aws_credentials_exist(self):
95 | """Check if AWS credentials are available."""
96 | session = boto3.session.Session()
97 | if session.get_credentials():
98 | return True
99 | return False
100 |
101 | def _settings(self):
102 | """Get settings for this plugin."""
103 | return sublime.load_settings(SETTINGS_PATH)
104 |
105 |
106 | class LambdaClient(AWSClient):
107 | """Common methods for Lambda commands."""
108 |
109 | def __init__(self, *arg):
110 | """Init Lambda client."""
111 | super()
112 | self.functions = []
113 |
114 | def _clear_client(self):
115 | _dbg("Clearing client")
116 | if '_aws_session' in globals():
117 | del globals()['_aws_session']
118 | _dbg("deleted _aws_session")
119 | if '_lambda_client' in globals():
120 | del globals()['_lambda_client']
121 | _dbg("deleted _lambda_client")
122 |
123 | @property
124 | def client(self):
125 | """Return AWS Lambda boto3 client."""
126 | if not self.test_aws_credentials_exist():
127 | self._clear_client()
128 | sublime.error_message(
129 | "AWS credentials not found.\n" +
130 | "Please follow the instructions at\n" +
131 | "https://pypi.python.org/pypi/boto3/")
132 | raise Exception("AWS credentials needed")
133 | if '_lambda_client' in globals() and globals()['_lambda_client']:
134 | _dbg("_lambda_client_exists")
135 | return globals()['_lambda_client']
136 | client = self.get_aws_client('lambda')
137 | globals()['_lambda_client'] = client
138 | return client
139 |
140 | def select_aws_profile(self, window):
141 | """Select a profile to use for our AWS session.
142 |
143 | Multiple profiles (access keys) can be defined in AWS credential configuration.
144 | """
145 | profiles = self.get_available_profiles()
146 | if len(profiles) <= 1:
147 | # no point in going any further eh
148 | return
149 |
150 | def profile_selected_cb(selected_index):
151 | if selected_index == -1:
152 | # cancelled
153 | return
154 | profile = profiles[selected_index]
155 | if not profile:
156 | return
157 | # save in settings
158 | self._settings().set("profile_name", profile)
159 | # clear the current session
160 | self._clear_client()
161 | window.status_message("Using AWS profile {}".format(profile))
162 | window.show_quick_panel(profiles, profile_selected_cb)
163 |
164 | def download_function(self, function):
165 | """Download source to a function and open it in a new window."""
166 | arn = function['FunctionArn']
167 | func_code_res = self.client.get_function(FunctionName=arn)
168 | url = func_code_res['Code']['Location']
169 | temp_dir_path = self.extract_zip_url(url)
170 | self.open_lambda_package_in_new_window(temp_dir_path, function)
171 |
172 | def extract_zip_url(self, file_url):
173 | """Fetch a zip file and decompress it.
174 |
175 | :returns: hash of filename to contents.
176 | """
177 | url = requests.get(file_url)
178 | with zipfile.ZipFile(io.BytesIO(url.content)) as zip:
179 | # extract to temporary directory
180 | temp_dir_path = tempfile.mkdtemp()
181 | print('created temporary directory', temp_dir_path)
182 | with cd(temp_dir_path):
183 | zip.extractall() # to cwd
184 | return temp_dir_path
185 |
186 | def zip_dir(self, dir_path):
187 | """Zip up a directory and all of its contents and return an in-memory zip file."""
188 | out = io.BytesIO()
189 | zip = zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED)
190 |
191 | # files to skip
192 | skip_re = re.compile("\.pyc$") # no compiled python files pls
193 | for root, dirs, files in os.walk(dir_path):
194 | # add files
195 | for file in files:
196 | file_path = os.path.join(root, file)
197 | in_zip_path = file_path.replace(dir_path, "", 1).lstrip("\\/")
198 | print("Adding file to lambda zip archive: '{}'".format(in_zip_path))
199 | if skip_re.search(in_zip_path): # skip this file?
200 | continue
201 | zip.write(file_path, in_zip_path)
202 | zip.close()
203 | if False:
204 | # debug
205 | zip.printdir()
206 | return out
207 |
208 | def upload_code(self, view, func):
209 | """Zip the temporary directory and upload it to AWS."""
210 | print(func)
211 | sublime_temp_path = func['sublime_temp_path']
212 | if not sublime_temp_path or not os.path.isdir(sublime_temp_path):
213 | print("error: failed to find temp lambda dir")
214 | # create zip archive, upload it
215 | try:
216 | view.set_status("lambda", "Creating lambda archive...")
217 | print("Creating zip archive...")
218 | zip_data = self.zip_dir(sublime_temp_path) # create in-memory zip archive of our temp dir
219 | zip_bytes = zip_data.getvalue() # read contents of BytesIO buffer
220 | except Exception as e:
221 | # view.show_popup("
Failed to save: {}
".format(html.escape(e))) 222 | self.display_error("Error creating zip archive for upload: {}".format(e)) 223 | view.set_status("lambda", "Failed to save lambda") 224 | else: 225 | # zip success? 226 | if zip_bytes: 227 | print("Created zip archive, len={}".format(len(zip_bytes))) 228 | # upload time 229 | try: 230 | print("Uploading lambda archive...") 231 | res = self.client.update_function_code( 232 | FunctionName=func['FunctionArn'], 233 | ZipFile=zip_bytes, 234 | ) 235 | except Exception as e: 236 | self.display_error("Error uploading lambda: {}".format(e)) 237 | view.set_status("lambda", "Failed to upload lambda") 238 | else: 239 | print("Upload successful.") 240 | view.set_status("lambda", "Lambda uploaded as {} [{} bytes]".format(res['FunctionName'], res['CodeSize'])) 241 | else: 242 | # got empty zip archive? 243 | view.set_status("lambda", "Failed to save lambda") 244 | 245 | def _load_functions(self, quiet=False): 246 | paginator = self.client.get_paginator('list_functions') 247 | if not quiet: 248 | sublime.status_message("Fetching lambda functions...") 249 | response_iterator = paginator.paginate() 250 | self.functions = [] 251 | try: 252 | for page in response_iterator: 253 | # print(page['Functions']) 254 | for func in page['Functions']: 255 | self.functions.append(func) 256 | except botocore.exceptions.ClientError as cerr: 257 | # display error fetching functions 258 | if not quiet: 259 | sublime.error_message(cerr.response['Error']['Message']) 260 | raise cerr 261 | if not quiet: 262 | sublime.status_message("Lambda functions fetched.") 263 | 264 | def select_function(self, callback): 265 | """Prompt to select a function then calls callback(function).""" 266 | self._load_functions() 267 | if not self.functions: 268 | sublime.message_dialog("No lambda functions were found.") 269 | return 270 | func_list = [] 271 | for func in self.functions: 272 | last_mod = func['LastModified'] # ugh 273 | # last_mod = last_mod.strftime("%Y-%m-%d %H:%M") 274 | func_list.append([ 275 | func['FunctionName'], 276 | func['Description'], 277 | "Last modified: {}".format(last_mod), 278 | "Runtime: {}".format(func['Runtime']), 279 | "Size: {}".format(func['CodeSize']), 280 | ]) 281 | 282 | def selected_cb(selected_index): 283 | if selected_index == -1: 284 | # cancelled 285 | return 286 | function = self.functions[selected_index] 287 | if not function: 288 | sublime.error_message("Unknown function selected.") 289 | callback(function) 290 | self.window.show_quick_panel(func_list, selected_cb) 291 | 292 | def display_function_info(self, function): 293 | """Create an output panel with the function details.""" 294 | if not isinstance(self, sublime_plugin.WindowCommand): 295 | raise Exception("display_function_info must be called on a WindowCommand") 296 | # v = self.window.create_output_panel("lambda_info_{}".format(function['FunctionName'])) 297 | nv = self.window.new_file() 298 | nv.view.set_scratch(True) 299 | nv.run_command("display_function_info", {'function': function}) 300 | 301 | def edit_function(self, function): 302 | """Edit a function's source.""" 303 | if not isinstance(self, sublime_plugin.WindowCommand): 304 | raise Exception("edit_function must be called on a WindowCommand") 305 | nv = self.window.create_output_panel("lambda_info_{}".format(function['FunctionName'])) 306 | nv.view.set_scratch(True) 307 | nv.run_command("edit_function", {'function': function}) 308 | 309 | def open_in_new_window(self, paths=[], cmd=None): 310 | """Open paths in a new sublime window.""" 311 | # from wbond https://github.com/titoBouzout/SideBarEnhancements/blob/st3/SideBar.py#L1916 312 | items = [] 313 | 314 | executable_path = sublime.executable_path() 315 | 316 | if sublime.platform() == 'osx': 317 | app_path = executable_path[:executable_path.rfind(".app/") + 5] 318 | executable_path = app_path + "Contents/SharedSupport/bin/subl" 319 | items.append(executable_path) 320 | if cmd: 321 | items.extend(['--command', cmd]) 322 | items.extend(paths) 323 | subprocess.Popen(items) 324 | 325 | def lambda_info_path(self, package_path): 326 | """Return path to the lambda info file for a downloaded package.""" 327 | return os.path.join(package_path, INFO_FILE_NAME) 328 | 329 | def open_lambda_package_in_new_window(self, package_path, function): 330 | """Spawn a new sublime window to edit an unzipped lambda package.""" 331 | # add a file to the directory to pass in our function info 332 | lambda_info_path = self.lambda_info_path(package_path) 333 | 334 | function['sublime_temp_path'] = package_path 335 | with open(lambda_info_path, 'w') as f: 336 | f.write(json.dumps(function)) 337 | self.open_in_new_window(paths=[package_path], cmd="prepare_lambda_window") 338 | 339 | def invoke_function(self, func): 340 | """Invoke a lambda function. 341 | 342 | :returns: return_object, log_output, error 343 | """ 344 | payload = {'sublime': True} 345 | res = self.client.invoke( 346 | FunctionName=func['FunctionName'], 347 | InvocationType='RequestResponse', # synchronous 348 | LogType='Tail', # give us last 4kb output in x-amz-log-result 349 | Payload=json.dumps(payload), 350 | ) 351 | # if res['FunctionError']: 352 | # self.display_error("Failed to invoke function: " + res['FunctionError']) 353 | # return 354 | 355 | # return value from the lambda 356 | res_payload = res['Payload'] 357 | if res_payload: 358 | res_payload = res_payload.read() 359 | # output 360 | res_log = res['LogResult'] 361 | if res_log: 362 | res_log = b64decode(res_log).decode('utf-8') 363 | err = None 364 | if 'FunctionError' in res: 365 | err = res['FunctionError'] 366 | return res_payload, res_log, err 367 | 368 | def invoke_function_test(self, function_name): 369 | """Ignore for now.""" 370 | self.invoke_function() 371 | 372 | def get_window_function(self, window): 373 | """Try to see if there is a function associated with this window. 374 | 375 | :returns: function info dict. 376 | """ 377 | proj_data = window.project_data() 378 | if not proj_data or 'lambda_function' not in proj_data: 379 | return 380 | func = proj_data['lambda_function'] 381 | return func 382 | 383 | def get_view_function(self, view): 384 | """Try to see if there is a function associated with this view.""" 385 | win = view.window() 386 | return self.get_window_function(win) 387 | 388 | def display_error(self, err): 389 | """Pop up an error message to the user.""" 390 | sublime.message_dialog(err) 391 | 392 | 393 | class PrepareLambdaWindowCommand(sublime_plugin.WindowCommand, LambdaClient): 394 | """Called when a lambda package has been downloaded and extracted and opened in a new window.""" 395 | 396 | def run(self): 397 | """Mark this project as being tied to a lambda function.""" 398 | win = self.window 399 | lambda_file_name = os.path.join(win.folders()[0], INFO_FILE_NAME) 400 | if not os.path.isfile(lambda_file_name): 401 | print(lambda_file_name + " does not exist") 402 | return 403 | lambda_file = open(lambda_file_name, 'r') 404 | func_info_s = lambda_file.read() 405 | lambda_file.close() 406 | if not func_info_s: 407 | print("Failed to read lambda file info") 408 | func_info = json.loads(func_info_s) 409 | proj_data = win.project_data() 410 | proj_data['lambda_function'] = func_info 411 | win.set_project_data(proj_data) 412 | 413 | # open default func file if it exists 414 | default_function_file = os.path.join(win.folders()[0], 'lambda_function.py') 415 | if os.path.isfile(default_function_file): 416 | win.open_file(default_function_file) 417 | 418 | 419 | class LambdaSaveHookListener(sublime_plugin.EventListener, LambdaClient): 420 | """Listener for events pertaining to editing lambdas.""" 421 | 422 | def on_post_save_async(self, view): 423 | """Sync modified lambda source.""" 424 | func = self.get_view_function(view) 425 | if not func: 426 | return 427 | # okay we're saving a lambda project! let's sync it back up! 428 | self.upload_code(view, func) 429 | 430 | 431 | class SelectEditFunctionCommand(sublime_plugin.WindowCommand, LambdaClient): 432 | """Fetch functions.""" 433 | 434 | def run(self): 435 | """Display choices in a quick panel.""" 436 | self.select_function(self.download_function) 437 | 438 | 439 | class SelectGetFunctionInfoCommand(sublime_plugin.WindowCommand, LambdaClient): 440 | """Display some handy info about a function.""" 441 | 442 | def run(self): 443 | """Display choices in a quick panel.""" 444 | self.select_function(self.display_function_info) 445 | 446 | 447 | class InvokeFunctionCommand(sublime_plugin.WindowCommand, LambdaClient): 448 | """Invoke current function.""" 449 | 450 | def run(self): 451 | """Display function invocation result in a new file.""" 452 | window = self.window 453 | func = self.get_window_function(window) 454 | if not func: 455 | self.display_error("No function is associated with this window.") 456 | return 457 | result, result_log, error_status = self.invoke_function(func) 458 | # display output 459 | nv = self.window.new_file() 460 | nv.set_scratch(True) 461 | fargs = dict( 462 | function=func, 463 | result=result.decode("utf-8"), 464 | result_log=result_log, 465 | error_status=error_status 466 | ) 467 | nv.run_command("display_invocation_result", fargs) 468 | 469 | def is_enabled(self): 470 | """Enable or disable option, depending on if the current window is associated with a function.""" 471 | func = self.get_window_function(self.window) 472 | if not func: 473 | return False 474 | return True 475 | 476 | 477 | class InstallDependencyCommand(sublime_plugin.WindowCommand, LambdaClient): 478 | """Install a package via pip.""" 479 | 480 | def run(self): 481 | """Call out to system to install packages via pip.""" 482 | window = self.window 483 | func = self.get_window_function(window) 484 | if not func: 485 | self.display_error("No lambda function is associated with this window.") 486 | return 487 | self.window.show_input_panel("PyPI Packages To Install:", '', lambda s: self._install_packages(func, s), None, None) 488 | 489 | def _install_packages(self, func, packages): 490 | if not packages: 491 | print("No packages selected to isntall") 492 | return 493 | 494 | cmd = """set -x \ 495 | rm -f pip.log; \ 496 | pip install --target pip/ --no-compile --log pip.log $LAMBDA_PACKAGES_TO_INSTALL \ 497 | && rm -rf pip/*.dist-info pip/tests \ 498 | && mv pip/* $PWD/ \ 499 | ; rm -rf pip 500 | """ 501 | cwd = func['sublime_temp_path'] 502 | env = dict(LAMBDA_PACKAGES_TO_INSTALL=packages) 503 | output = "