├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── NOTICE.txt ├── README.md ├── THIRD_PARTY_LICENSES.txt ├── fdk ├── __init__.py ├── async_http │ ├── __init__.py │ ├── app.py │ ├── error_handler.py │ ├── exceptions.py │ ├── protocol.py │ ├── request.py │ ├── response.py │ ├── router.py │ ├── server.py │ └── signpost.md ├── constants.py ├── context.py ├── customer_code.py ├── errors.py ├── event_handler.py ├── fixtures.py ├── headers.py ├── log.py ├── response.py ├── runner.py ├── scripts │ ├── __init__.py │ ├── fdk.py │ └── fdk_tcp_debug.py ├── tests │ ├── __init__.py │ ├── funcs.py │ ├── tcp_debug.py │ ├── test_delayed_loader.py │ ├── test_headers.py │ ├── test_http_stream.py │ ├── test_protocol.py │ └── test_tracing.py └── version.py ├── generate_func.sh ├── requirements.txt ├── samples ├── __init__.py └── echo │ ├── __init__.py │ └── func.py ├── setup.cfg ├── setup.py ├── test-requirements.txt └── 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 | rpm-package/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | .idea/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | .venv 89 | venv/ 90 | ENV/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | .spyproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # mkdocs documentation 100 | /site 101 | 102 | # mypy 103 | .mypy_cache/ 104 | *.pyc 105 | .testrepository 106 | .tox/* 107 | dist/* 108 | build/* 109 | html/* 110 | *.egg* 111 | cover/* 112 | .coverage 113 | rdserver.txt 114 | python-troveclient.iml 115 | 116 | # Files created by releasenotes build 117 | releasenotes/build 118 | .coverage.* 119 | .cache 120 | *.log* 121 | *.bak 122 | *.csv 123 | venv 124 | .venv 125 | ChangeLog 126 | AUTHORS 127 | .pytest_cache/ 128 | data/ 129 | 130 | func.yml 131 | test_function/ 132 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | 0.1.93 5 | ------ 6 | 7 | * FDK Python: 0.1.93 version release 8 | 9 | 0.1.92 10 | ------ 11 | 12 | * FDK Python: 0.1.92 version release 13 | 14 | 0.1.91 15 | ------ 16 | 17 | * FDK Python: 0.1.91 version release 18 | 19 | 0.1.90 20 | ------ 21 | 22 | * FDK Python: 0.1.90 version release 23 | 24 | 0.1.89 25 | ------ 26 | 27 | * FDK Python: 0.1.89 version release 28 | 29 | 0.1.88 30 | ------ 31 | 32 | * FDK Python: 0.1.88 version release 33 | 34 | 0.1.87 35 | ------ 36 | 37 | * FDK Python: 0.1.87 version release 38 | 39 | 0.1.86 40 | ------ 41 | 42 | * FDK Python: 0.1.86 version release 43 | 44 | 0.1.85 45 | ------ 46 | 47 | * FDK Python: 0.1.85 version release 48 | 49 | 0.1.84 50 | ------ 51 | 52 | * FDK Python: 0.1.84 version release 53 | 54 | 0.1.83 55 | ------ 56 | 57 | * FDK Python: 0.1.83 version release 58 | 59 | 0.1.82 60 | ------ 61 | 62 | * FDK Python: 0.1.82 version release 63 | 64 | 0.1.81 65 | ------ 66 | 67 | * FDK Python: 0.1.81 version release 68 | 69 | 0.1.80 70 | ------ 71 | 72 | * FDK Python: 0.1.80 version release 73 | 74 | 0.1.79 75 | ------ 76 | 77 | * FDK Python: 0.1.79 version release 78 | 79 | 0.1.78 80 | ------ 81 | 82 | * FDK Python: 0.1.78 version release 83 | 84 | 0.1.77 85 | ------ 86 | 87 | * FDK Python: 0.1.77 version release 88 | 89 | 0.1.76 90 | ------ 91 | 92 | * FDK Python: 0.1.76 version release 93 | 94 | 0.1.75 95 | ------ 96 | 97 | * FDK Python: 0.1.75 version release 98 | 99 | 0.1.74 100 | ------ 101 | 102 | * FDK Python: 0.1.74 version release 103 | 104 | 0.1.73 105 | ------ 106 | 107 | * FDK Python: 0.1.73 version release 108 | 109 | 0.1.72 110 | ------ 111 | 112 | * FDK Python: 0.1.72 version release 113 | 114 | 0.1.71 115 | ------ 116 | 117 | * FDK Python: 0.1.71 version release 118 | 119 | 0.1.70 120 | ------ 121 | 122 | * FDK Python: 0.1.70 version release 123 | 124 | 0.1.69 125 | ------ 126 | 127 | * FDK Python: 0.1.69 version release 128 | 129 | 0.1.68 130 | ------ 131 | 132 | * FDK Python: 0.1.68 version release 133 | 134 | 0.1.67 135 | ------ 136 | 137 | * FDK Python: 0.1.67 version release 138 | 139 | 0.1.66 140 | ------ 141 | 142 | * FDK Python: 0.1.66 version release 143 | 144 | 0.1.65 145 | ------ 146 | 147 | * FDK Python: 0.1.65 version release 148 | 149 | 0.1.64 150 | ------ 151 | 152 | * FDK Python: 0.1.64 version release 153 | 154 | 0.1.63 155 | ------ 156 | 157 | * FDK Python: 0.1.63 version release 158 | 159 | 0.1.62 160 | ------ 161 | 162 | * FDK Python: 0.1.62 version release 163 | 164 | 0.1.61 165 | ------ 166 | 167 | * FDK Python: 0.1.61 version release 168 | 169 | 0.1.60 170 | ------ 171 | 172 | * FDK Python: 0.1.60 version release 173 | 174 | 0.1.59 175 | ------ 176 | 177 | * FDK Python: 0.1.59 version release 178 | 179 | 0.1.58 180 | ------ 181 | 182 | * FDK Python: 0.1.58 version release 183 | 184 | 0.1.57 185 | ------ 186 | 187 | * FDK Python: 0.1.57 version release 188 | 189 | 0.1.56 190 | ------ 191 | 192 | * FDK Python: 0.1.56 version release 193 | 194 | 0.1.55 195 | ------ 196 | 197 | * FDK Python: 0.1.55 version release 198 | 199 | 0.1.54 200 | ------ 201 | 202 | * FDK Python: 0.1.54 version release 203 | 204 | 0.1.53 205 | ------ 206 | 207 | * FDK Python: 0.1.53 version release 208 | 209 | 0.1.52 210 | ------ 211 | 212 | * FDK Python: 0.1.52 version release 213 | 214 | 0.1.51 215 | ------ 216 | 217 | * FDK Python: 0.1.51 version release 218 | 219 | 0.1.50 220 | ------ 221 | 222 | * FDK Python: 0.1.50 version release 223 | 224 | 0.1.49 225 | ------ 226 | 227 | * FDK Python: 0.1.49 version release 228 | 229 | 0.1.48 230 | ------ 231 | 232 | * FDK Python: 0.1.48 version release 233 | 234 | 0.1.49 235 | ------ 236 | 237 | * FDK Python: 0.1.49 version release 238 | 239 | 0.1.48 240 | ------ 241 | 242 | * FDK Python: 0.1.48 version release 243 | 244 | 0.1.47 245 | ------ 246 | 247 | * FDK Python: 0.1.47 version release 248 | 249 | 0.1.46 250 | ------ 251 | 252 | * FDK Python: 0.1.46 version release 253 | 254 | 0.1.45 255 | ------ 256 | 257 | * FDK Python: 0.1.45 version release 258 | 259 | 0.1.44 260 | ------ 261 | 262 | * FDK Python: 0.1.44 version release 263 | 264 | 0.1.43 265 | ------ 266 | 267 | * FDK Python: 0.1.43 version release 268 | 269 | 0.1.42 270 | ------ 271 | 272 | * FDK Python: 0.1.42 version release 273 | 274 | 0.1.41 275 | ------ 276 | 277 | * FDK Python: 0.1.41 version release 278 | 279 | 0.1.40 280 | ------ 281 | 282 | * FDK Python: 0.1.40 version release 283 | 284 | 0.1.39 285 | ------ 286 | 287 | * FDK Python: 0.1.39 version release 288 | 289 | 0.1.38 290 | ------ 291 | 292 | * FDK Python: 0.1.38 version release 293 | 294 | 0.1.37 295 | ------ 296 | 297 | * FDK Python: 0.1.37 version release 298 | 299 | 0.1.36 300 | ------ 301 | 302 | * FDK Python: 0.1.36 version release 303 | 304 | 0.1.35 305 | ------ 306 | 307 | * FDK Python: 0.1.35 version release 308 | 309 | 0.1.34 310 | ------ 311 | 312 | * FDK Python: 0.1.34 version release 313 | 314 | 0.1.33 315 | ------ 316 | 317 | * FDK Python: 0.1.33 release [skip ci] 318 | 319 | 0.1.32 320 | ------ 321 | 322 | * FDK Python: 0.1.32 release [skip ci] 323 | 324 | 0.1.31 325 | ------ 326 | 327 | * FDK Python: 0.1.31 release [skip ci] 328 | 329 | 0.1.30 330 | ------ 331 | 332 | * FDK Python: 0.1.30 release [skip ci] 333 | 334 | 0.1.29 335 | ------ 336 | 337 | * FDK Python: 0.1.29 release [skip ci] 338 | 339 | 0.1.28 340 | ------ 341 | 342 | * FDK Python: 0.1.28 release [skip ci] 343 | 344 | 0.1.27 345 | ------ 346 | 347 | * FDK Python: 0.1.27 release [skip ci] 348 | 349 | 0.1.26 350 | ------ 351 | 352 | * FDK Python: 0.1.26 release [skip ci] 353 | * Roneet| removed deadline unit testcase 354 | * Roneet| removed deadline unit testcase 355 | * Roneet | removed timeout handling code as its managed by runner layer 356 | 357 | 0.1.25 358 | ------ 359 | 360 | * FDK Python: 0.1.25 release [skip ci] 361 | * Fix to the zipkin\_attrs 362 | 363 | 0.1.24 364 | ------ 365 | 366 | * FDK Python: 0.1.24 release [skip ci] 367 | * - Added license files and other files to build wheel - Started tracking CHANGELOG.md in source 368 | 369 | 0.1.23 370 | ------ 371 | 372 | * FDK Python: 0.1.23 release [skip ci] 373 | * Added license files to wheel 374 | 375 | 0.1.22 376 | ------ 377 | 378 | * FDK Python: 0.1.22 release [skip ci] 379 | * Add support for Oracle Cloud Infrastructure tracing solution by providing a tracing context #117 380 | * Add support for Oracle Cloud Infrastructure tracing solution by providing a tracing context 381 | * - Added versions 3.7 and 3.8 to FDK - Added scripts to build images properly 382 | 383 | 0.1.21 384 | ------ 385 | 386 | * FDK Python: 0.1.21 release [skip ci] 387 | 388 | 0.1.20 389 | ------ 390 | 391 | * FDK Python: 0.1.20 release [skip ci] 392 | * Fix dependency issue with python 3.6.0 393 | 394 | 0.1.19 395 | ------ 396 | 397 | * FDK Python: 0.1.19 release [skip ci] 398 | * Updated copyright headers, added NOTICE.txt and updated THIRD\_PARTY\_LICENSES.txt 399 | * Set root logger to DEBUG to preserve existing log level behaviour 400 | * Format logs with call ID and one-line exceptions 401 | * Moved FDK logging to debug, and turned debug off by default 402 | * Updated README.md 403 | 404 | 0.1.18 405 | ------ 406 | 407 | * FDK Python: 0.1.18 release [skip ci] 408 | * Fix "None-None" race condition with keep-alive timeout 409 | 410 | 0.1.17 411 | ------ 412 | 413 | * FDK Python: 0.1.17 release [skip ci] 414 | * Satisfy pep8 warnings 415 | * Add Python 3.8.5 416 | 417 | 0.1.16 418 | ------ 419 | 420 | * FDK Python: 0.1.16 release [skip ci] 421 | * Support sending response as binary data 422 | 423 | 0.1.15 424 | ------ 425 | 426 | * FDK Python: 0.1.15 release [skip ci] 427 | * Refresh the Python FDK dependencies to more recent versions 428 | 429 | 0.1.14 430 | ------ 431 | 432 | * FDK Python: 0.1.14 release [skip ci] 433 | * Fix issue with header prefixes, remove ambiguous header processing, add test 434 | 435 | 0.1.13 436 | ------ 437 | 438 | * FDK Python: 0.1.13 release [skip ci] 439 | * Add third party license file 440 | 441 | 0.1.12 442 | ------ 443 | 444 | * FDK Python: 0.1.12 release [skip ci] 445 | * Turn off CircleCI docker layer caching 446 | 447 | 0.1.11 448 | ------ 449 | 450 | * FDK Python: 0.1.11 release [skip ci] 451 | * Pin attrs==19.1.0 as just released attrs 19.2.0 is breaking pytest https://github.com/pytest-dev/pytest/issues/5901 452 | * fix formatting issues 453 | * fix value of fn-fdk-version to include the fdk language. format: fdk-python/x.y.z 454 | * ensure workflow works 455 | * Ensure that CI pipeline works 456 | * Fix request URL and HTTP method definitions 457 | * Adding necessary git config vars to do commits from inside of the CI jobs (#93) 458 | * Enable docker images release (#92) 459 | * Fixing version placement command 460 | * properly grab content type (#90) 461 | * Disable image release procedure temporarily (#89) 462 | 463 | 0.1.6 464 | ----- 465 | 466 | * FDK Python: 0.1.6 release [skip ci] 467 | * fix gateway headers / headers casing (#88) 468 | * removing corresponding TODO 469 | * adding FDK release into the CI 470 | * New PBR release broke setup config 471 | * Automated releases + FDK version header (#82) 472 | * attempt fixing content type issue (#83) 473 | * Adding Anchore CI check (#77) 474 | * If enabled, print log framing content to stdout/stderr (#81) 475 | * Add fn user/group to 3.6 runtime image (#79) 476 | * Runtime image updated: fn user/group fix 477 | * Bring all Dockerfiles for supported Python runtimes into FDK repo (#76) 478 | * fn user added to runtime image 479 | * Make gen script add build/run images into func.yaml (#73) 480 | * Script to generate and deploy a function from FDK git branch (#72) 481 | * Adding Py3.5.2+ support (#70) 482 | * changing request header fields in context to lower case (#67) 483 | * Comply with internal Fn response code management (#65) 484 | * Fix setuptools classfier reference 485 | * asyncio + h11 (was #58) (#60) 486 | * cleaning up code samples 487 | * Removing application's code 488 | * No py2 builds (#57) 489 | * Requirements update (#56) 490 | * Fixing unit test section in README (#55) 491 | * Fn applications, request content reading fix (#54) 492 | * HTTP stream format support (#51) 493 | * Feature: unittest your function (#47) 494 | * Fix protocol key addressing (#46) 495 | * Moving docker images to FDK repo as officially supported (#45) 496 | * Error handling improvement: default error content type to application/json 497 | * Improvement: make FDK return valid raw XML/HTML content (#42) 498 | * GoLikeHeaders.set() logic improvements (#41) 499 | * Adjust parser to make fn-test fn-run at least spit out the response (#40) 500 | * Refactoring. CloudEvents (#39) 501 | * Fix headers rendering (#38) 502 | * Hotfix: str -> bytes 503 | * Try to identify the end of th request (#35) 504 | * More debug information (#34) 505 | * Stable parser! (#33) 506 | * Fn-powered applications improvements (#32) 507 | * Making fn-powered apps API stable (#31) 508 | * General improvements (#30) 509 | * Get rid of unstable coroutines (#29) 510 | * Attempt to create new loop for each request 511 | * Trying to fix the event loop access 512 | * Use deadlines for coroutines 513 | * Playing with an executor 514 | * More logging 515 | * Coroutine hotfix (#28) 516 | 517 | v0.0.12 518 | ------- 519 | 520 | * Do not attempt to jsonify response data (#27) 521 | 522 | v0.0.11 523 | ------- 524 | 525 | * Run coroutines thread-safely (#26) 526 | * +x mode for release script 527 | * Fix script name 528 | * Update release script 529 | * Coroutine support (#25) 530 | 531 | v0.0.9 532 | ------ 533 | 534 | * Custom response objects (#24) 535 | * New JSON parser! (#22) 536 | * More wise JSON parsing with en empty body (#21) 537 | 538 | v0.0.7 539 | ------ 540 | 541 | * Check the request body before trying to turn that into a json (#20) 542 | 543 | v0.0.6 544 | ------ 545 | 546 | * New JSON protocol improvements (#19) 547 | 548 | v0.0.5 549 | ------ 550 | 551 | * FDK Context| deadline | generic response, etc. (#16) 552 | * Fix pytest capture (#12) 553 | * Stable v0.0.4 release (#11) 554 | 555 | v0.0.4 556 | ------ 557 | 558 | * Updating to newer JSON protocol (#10) 559 | * Fixing release doc (#9) 560 | * Stable v0.0.3 release (#8) 561 | * Fn-powered truly-serverless apps with functions (#7) 562 | 563 | v0.0.2 564 | ------ 565 | 566 | * Stable release v0.0.2 (#6) 567 | * Applications powered by Fn (#5) 568 | * Fixing root Dockerfile 569 | * Updating samples deps to fdk-0.0.1 570 | 571 | v0.0.1 572 | ------ 573 | 574 | * Fixing CircleCI again 575 | * Fixing CircleCI config 576 | * Adding circle CI config 577 | * Attempting to pick appropriate binary 578 | * Updating tox config for CircleCI 579 | * Updating artifact name 580 | * Refactoring to be a single handler based on FN\_FORMAT 581 | * Get rid of verbose response in favour of http lib 582 | * Implementing generic handler based on FN\_FORMAT 583 | * Updating sample apps 584 | * Updating root Dockerfile 585 | * JSON parser added 586 | * Addressing review comments 587 | * Initial commit 588 | * Initial commit 589 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | global-include requirements.txt test-requirements.txt 2 | exclude ChangeLog 3 | exclude github.whitelist 4 | exclude *.bak 5 | recursive-exclude internal * 6 | -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | Fn Project 2 | ============ 3 | 4 | Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | 18 | ========================================================================== 19 | Third Party Dependencies 20 | ========================================================================== 21 | 22 | This project includes or depends on code from third party projects. 23 | Attributions are contained in THIRD_PARTY_LICENSES.txt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Function development kit for Python 2 | The python FDK lets you write functions in python 3.8/3.9/3.11 3 | 4 | ## Simplest possible function 5 | 6 | ```python 7 | import io 8 | import logging 9 | 10 | from fdk import response 11 | 12 | def handler(ctx, data: io.BytesIO = None): 13 | logging.getLogger().info("Got incoming request") 14 | return response.Response(ctx, response_data="hello world") 15 | ``` 16 | 17 | 18 | ## Handling HTTP metadata in HTTP Functions 19 | Functions can implement HTTP services when fronted by an HTTP Gateway 20 | 21 | When your function is behind an HTTP gateway you can access the inbound HTTP Request via : 22 | 23 | - `ctx.HttpHeaders()` : a map of string -> value | list of values , unlike `ctx.Headers()` this only includes headers 24 | passed by the HTTP gateway (with no functions metadata). 25 | - `ctx.RequestURL()` : the incoming request URL passed by the gateway 26 | - `ctx.Method()` : the HTTP method of the incoming request 27 | 28 | You can set outbound HTTP headers and the HTTP status of the request using `ctx.SetResponseHeaders` or the `Response` 29 | - e.g. `ctx.SetResponseHeaders({"Location","http://example.com/","My-Header2": ["v1","v2"]}, 302)` 30 | - or by passing these to the Response object : 31 | ```python 32 | return new Response( 33 | ctx, 34 | headers={"Location","http://example.com/","My-Header2": ["v1","v2"]}, 35 | response_data="Page moved", 36 | status_code=302) 37 | ``` 38 | 39 | e.g. to redirect users to a different page : 40 | ```python 41 | import io 42 | import logging 43 | 44 | from fdk import response 45 | 46 | def handler(ctx, data: io.BytesIO = None): 47 | logging.getLogger().info("Got incoming request for URL %s with headers %s", ctx.RequestURL(), ctx.HTTPHeaders()) 48 | ctx.SetResponseHeaders({"Location": "http://www.example.com"}, 302) 49 | return response.Response(ctx, response_data="Page moved from %s") 50 | ``` 51 | 52 | 53 | ## Handling JSON in Functions 54 | 55 | A main loop is supplied that can repeatedly call a user function with a series of requests. 56 | In order to utilise this, you can write your `func.py` as follows: 57 | 58 | ```python 59 | import json 60 | import io 61 | 62 | from fdk import response 63 | 64 | def handler(ctx, data: io.BytesIO=None): 65 | name = "World" 66 | try: 67 | body = json.loads(data.getvalue()) 68 | name = body.get("name") 69 | except (Exception, ValueError) as ex: 70 | print(str(ex)) 71 | pass 72 | 73 | return response.Response( 74 | ctx, response_data=json.dumps( 75 | {"message": "Hello {0}".format(name)}), 76 | headers={"Content-Type": "application/json"} 77 | ) 78 | 79 | ``` 80 | 81 | ## Writing binary data from functions 82 | In order to write a binary response to your function pass a `bytes` object to the response_data 83 | 84 | ```python 85 | import io 86 | from PIL import Image, ImageDraw 87 | from fdk import response 88 | 89 | 90 | def handler(ctx, data: io.BytesIO=None): 91 | img = Image.new('RGB', (100, 30), color='red') 92 | d = ImageDraw.Draw(img) 93 | d.text((10, 10), "hello world", fill=(255, 255, 0)) 94 | # write png image to memory 95 | output = io.BytesIO() 96 | img.save(output, format="PNG") 97 | # get the bytes of the image 98 | imgbytes = output.getvalue() 99 | 100 | return response.Response( 101 | ctx, response_data=imgbytes, 102 | headers={"Content-Type": "image/png"} 103 | ) 104 | ``` 105 | 106 | 107 | 108 | ## Unit testing your functions 109 | 110 | Starting v0.0.33 FDK-Python provides a testing framework that allows performing unit tests of your function's code. 111 | The unit test framework is the [pytest](https://pytest.org/). Coding style remain the same, so, write your tests as you've got used to. 112 | Here's the example of the test suite: 113 | ```python 114 | import json 115 | import io 116 | import pytest 117 | 118 | from fdk import fixtures 119 | from fdk import response 120 | 121 | 122 | def handler(ctx, data: io.BytesIO=None): 123 | name = "World" 124 | try: 125 | body = json.loads(data.getvalue()) 126 | name = body.get("name") 127 | except (Exception, ValueError) as ex: 128 | print(str(ex)) 129 | pass 130 | 131 | return response.Response( 132 | ctx, response_data=json.dumps( 133 | {"message": "Hello {0}".format(name)}), 134 | headers={"Content-Type": "application/json"} 135 | ) 136 | 137 | 138 | @pytest.mark.asyncio 139 | async def test_parse_request_without_data(): 140 | call = await fixtures.setup_fn_call(handler) 141 | 142 | content, status, headers = await call 143 | 144 | assert 200 == status 145 | assert {"message": "Hello World"} == json.loads(content) 146 | 147 | ``` 148 | 149 | As you may see all assertions being performed with native assertion command. 150 | 151 | In order to run tests, use the following command: 152 | ```bash 153 | pytest -v -s --tb=long func.py 154 | ``` 155 | 156 | ```bash 157 | ========================================================================================= test session starts ========================================================================================== 158 | platform darwin -- Python 3.7.1, pytest-4.0.1, py-1.7.0, pluggy-0.8.0 -- /python/bin/python3 159 | cachedir: .pytest_cache 160 | rootdir: /Users/denismakogon/go/src/github.com/fnproject/test, inifile: 161 | plugins: cov-2.4.0, asyncio-0.9.0, aiohttp-0.3.0 162 | collected 1 item 163 | 164 | func.py::test_parse_request_without_data 2018-12-10 15:42:30,029 - asyncio - DEBUG - Using selector: KqueueSelector 165 | 2018-12-10 15:42:30,029 - asyncio - DEBUG - Using selector: KqueueSelector 166 | 'NoneType' object has no attribute 'getvalue' 167 | {'Fn-Http-Status': '200', 'Content-Type': 'application/json'} 168 | PASSED 169 | 170 | ======================================================================================= 1 passed in 0.02 seconds ======================================================================================= 171 | ``` 172 | 173 | To add coverage first install one more package: 174 | ```bash 175 | pip install pytest-cov 176 | ``` 177 | then run tests with coverage flag: 178 | ```bash 179 | pytest -v -s --tb=long --cov=func func.py 180 | ``` 181 | 182 | ```bash 183 | pytest -v -s --tb=long --cov=func func.py 184 | ========================================================================================= test session starts ========================================================================================== 185 | platform darwin -- Python 3.7.1, pytest-4.0.1, py-1.7.0, pluggy-0.8.0 -- /python/bin/python3 186 | cachedir: .pytest_cache 187 | rootdir: /Users/denismakogon/go/src/github.com/fnproject/test, inifile: 188 | plugins: cov-2.4.0, asyncio-0.9.0, aiohttp-0.3.0 189 | collected 1 item 190 | 191 | func.py::test_parse_request_without_data 2018-12-10 15:43:10,339 - asyncio - DEBUG - Using selector: KqueueSelector 192 | 2018-12-10 15:43:10,339 - asyncio - DEBUG - Using selector: KqueueSelector 193 | 'NoneType' object has no attribute 'getvalue' 194 | {'Fn-Http-Status': '200', 'Content-Type': 'application/json'} 195 | PASSED 196 | 197 | ---------- coverage: platform darwin, python 3.7.1-final-0 ----------- 198 | Name Stmts Miss Cover 199 | ----------------------------- 200 | func.py 19 1 95% 201 | 202 | 203 | ======================================================================================= 1 passed in 0.06 seconds ======================================================================================= 204 | ``` 205 | 206 | ## FDK tooling 207 | 208 | ## Installing tools 209 | 210 | Create a virtualenv: 211 | ```bash 212 | python3 -m venv .venv 213 | ``` 214 | Activate virtualenv: 215 | ```bash 216 | source .venv/bin/activate 217 | ``` 218 | All you have to do is: 219 | ```bash 220 | pip install fdk 221 | ``` 222 | Now you have a new tools added! 223 | 224 | ## Tools 225 | 226 | With a new FDK release a new set of tooling introduced: 227 | 228 | - `fdk` - CLI tool, an entry point to a function, that's the way you start your function in real life 229 | - `fdk-tcp-debug` - CLI tool, an entry point to a function local debugging 230 | 231 | ## CLI tool: `fdk` 232 | 233 | This is an entry point to a function, this tool you'd be using while working with a function that is deployed at Fn server. 234 | 235 | ### Usage 236 | 237 | `fdk` is a Python CLI script that has the following signature: 238 | 239 | ```bash 240 | fdk [module-entrypoint] 241 | ``` 242 | 243 | where: 244 | - `fdk` is a CLI script 245 | - `` is a path to your function's code, for instance, `/function/func.py` 246 | - `[module-entrypoint]` is an entry point to a module, basically you need to point to a method that has the following signature: 247 | `def (ctx, data: io.BytesIO=None)`, as you many notice this is a ordinary signature of Python's function you've used to while working with an FDK, 248 | 249 | The parameter `[module-entrypoint]` has a default value: `handler`. It means that if a developer will point an `fdk` CLI to a module `func.py`: 250 | 251 | ``` 252 | fdk func.py 253 | ``` 254 | 255 | the CLI will look for `handler` Python function. 256 | In order to override `[module-entrypoint]` you need to specify your custom entry point. 257 | 258 | ### Testing locally 259 | 260 | To run a function locally (outside Docker) you need to set `FN_FORMAT` and `FN_LISTENER`, like so: 261 | 262 | ```bash 263 | env FDK_DEBUG=1 FN_FORMAT=http-stream FN_LISTENER=unix://tmp/func.sock fdk [module-entrypoint] 264 | ``` 265 | 266 | You can then test with curl: 267 | 268 | ```bash 269 | curl -v --unix-socket /tmp/func.sock -H "Fn-Call-Id: 0000000000000000" -H "Fn-Deadline: 2030-01-01T00:00:00.000Z" -XPOST http://function/call -d '{"name":"Tubbs"}' 270 | ``` 271 | 272 | ## CLI tool: `fdk-tcp-debug` 273 | 274 | The reason why this tool exists is to give a chance to developers to debug their function on their machines. 275 | There's no difference between this tool and `fdk` CLI tool, except one thing: `fdk` works on top of the unix socket, 276 | when this tool works on top of TCP socket, so, the difference is a transport, nothing else. 277 | 278 | #### Usage 279 | 280 | `fdk-tcp-debug` is a Python CLI script that has the following signature: 281 | 282 | ```bash 283 | fdk-tcp-debug [module-entrypoint] 284 | ``` 285 | 286 | The behaviour of this CLI is the same, but it will start an FDK on top of the TCP socket. 287 | The only one difference is that this CLI excepts one more parameter: `port` that is required by TCP socket configuration. 288 | 289 | Now you can test your functions not only with the unit tests but also see how it works within the FDK before actually deploying them to Fn server. 290 | 291 | 292 | ## Developing and testing an FDK 293 | 294 | If you decided to develop an FDK please do the following: 295 | 296 | - open an issue with the detailed description of your problem 297 | - checkout a new branch with the following signature: `git checkout -b issue-` 298 | 299 | In order to test an FDK changes do the following: 300 | 301 | - `python3 -m venv .venv && source .venv/bin/activate` 302 | - `pip install tox` 303 | - `tox` 304 | 305 | ### Testing with `fdk-tcp-debug` 306 | 307 | Test an FDK change with sample function using `fdk-tcp-debug`: 308 | 309 | ```bash 310 | pip install -e . 311 | FDK_DEBUG=1 fdk-tcp-debug 5001 samples/echo/func.py handler 312 | ``` 313 | 314 | Then just do: 315 | 316 | ```bash 317 | curl -v -X POST localhost:5001 -d '{"name":"denis"}' 318 | ``` 319 | 320 | ### Testing within a function 321 | 322 | First of all create a test function: 323 | ```bash 324 | fn init --runtime python3.8 test-function 325 | ``` 326 | 327 | Create a Dockerfile in a function's folder: 328 | ```dockerfile 329 | FROM fnproject/python:3.8-dev as build-stage 330 | 331 | ADD . /function 332 | WORKDIR /function 333 | 334 | RUN pip3 install --target /python/ --no-cache --no-cache-dir fdk-test-py3-none-any.whl 335 | 336 | RUN rm -fr ~/.cache/pip /tmp* requirements.txt func.yaml Dockerfile .venv 337 | 338 | FROM fnproject/python:3.8 339 | 340 | COPY --from=build-stage /function /function 341 | COPY --from=build-stage /python /python 342 | ENV PYTHONPATH=/python 343 | 344 | ENTRYPOINT ["/python/bin/fdk", "/function/func.py", "handler"] 345 | ``` 346 | 347 | Build an FDK wheel: 348 | ```bash 349 | pip install wheel 350 | PBR_VERSION=test python setup.py bdist_wheel 351 | ``` 352 | 353 | Move an FDK wheel (located at `dist/fdk-test-py3-none-any.whl`) into a function's folder. 354 | 355 | Do the deploy: 356 | ```bash 357 | fn --versbose deploy --app testapp --local --no-bump 358 | fn config fn testapp test-function FDK_DEBUG 1 359 | ``` 360 | 361 | And the last step - invoke it and see how it goes: 362 | ```bash 363 | fn invoke testapp test-function 364 | ``` 365 | 366 | ## Speeding up an FDK 367 | 368 | FDK is based on the asyncio event loop. Default event loop is not quite fast, but works on all operating systems (including Windows), 369 | In order to make an FDK to process IO operation at least 4 times faster you need to add another dependency to your function: 370 | 371 | ```text 372 | uvloop 373 | ``` 374 | 375 | [UVLoop](https://github.com/MagicStack/uvloop) is a CPython wrapper on top of cross-platform [libuv](https://github.com/libuv/libuv). 376 | Unfortunately, uvloop doesn't support Windows for some reason, so, in order to let developers test their code on Windows 377 | FDK doesn't install uvloop by default, but still has some checks to see whether it is installed or not. 378 | 379 | 380 | ## Migration path 381 | 382 | As if you are the one who used Python FDK before and would like to update - please read this section carefully. 383 | A new FDK is here which means there suppose to be a way to upgrade your code from an old-style FDK to a new-style FDK. 384 | 385 | ### No `__main__` definition 386 | 387 | As you noticed - an entry point a function changed, i.e., func.py no longer considered as the main module (`__main__`) which means that the following section: 388 | 389 | ```python 390 | if __name__ == "__main__": 391 | fdk.handle(handler) 392 | ``` 393 | 394 | has no effect any longer. Please note that FDK will fail-fast with an appropriate message if old-style FDK format used. 395 | 396 | ### `data` type has changed 397 | 398 | With a new FDK, `data` parameter is changing from `str` to `io.BytesIO`. 399 | The simplest way to migrate is to wrap your data processing code with 1 line of code: 400 | ```python 401 | data = data.read() 402 | ``` 403 | 404 | If you've been using json lib to turn an incoming data into a dictionary you need to replace: `json.loads` with `json.load` 405 | 406 | ```python 407 | try: 408 | dct = json.load(data) 409 | except ValueError as ex: 410 | # do here whatever is reasonable 411 | ``` 412 | 413 | ### Dockerfile 414 | If you've been using CLI to build function without modifying runtime in `func.yaml` to `docker` 415 | instead of `python` then the only thing you need is to update the CLI to the latest version and 416 | pin your Python runtime version to `python`, `python3.7`, `python3.8`, or `python3.9`, or `python3.11` . 417 | 418 | If you've been using custom multi-stage Dockerfile (derived from what Fn CLI generates) 419 | the only thing that is necessary to change is an `ENTRYPOINT` from: 420 | 421 | ```text 422 | ENTRYPOINT["python", "func.py"] 423 | ``` 424 | 425 | to: 426 | 427 | ```text 428 | ENTRYPOINT["/python/bin/fdk", "func.py", "handler"] 429 | ``` 430 | 431 | If you've been using your own Dockerfile that wasn't derived from the Dockerfile 432 | that CLI is generating, then you need to search in your `$PATH` where CLI fdk was installed 433 | (on Linux, it will be installed to `/usr/local/bin/fdk`). At most of the times, if you've been using: 434 | 435 | ```text 436 | pip install --target ... 437 | ``` 438 | 439 | then you need to search fdk CLI at `/bin/fdk`, this is what Fn CLI does by calling the following command: 440 | 441 | ```text 442 | pip install --target /python ... 443 | ``` 444 | 445 | ## Notes 446 | 447 | A new FDK will abort a function execution if old-style function definition is used. 448 | Make sure you check you migrated your code wisely. 449 | -------------------------------------------------------------------------------- /fdk/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import asyncio 18 | import os 19 | import socket 20 | import sys 21 | 22 | from fdk import constants 23 | from fdk import event_handler 24 | from fdk import customer_code 25 | from fdk import log 26 | 27 | from fdk.async_http import app 28 | from fdk.async_http import router 29 | 30 | 31 | def start(handle_code: customer_code.Function, 32 | uds: str, 33 | loop: asyncio.AbstractEventLoop = None): 34 | """ 35 | Unix domain socket HTTP server entry point 36 | :param handle_code: customer's code 37 | :type handle_code: fdk.customer_code.Function 38 | :param uds: path to a Unix domain socket 39 | :type uds: str 40 | :param loop: event loop 41 | :type loop: asyncio.AbstractEventLoop 42 | :return: None 43 | """ 44 | log.log("in http_stream.start") 45 | socket_path = os.path.normpath(str(uds).lstrip("unix:")) 46 | socket_dir, socket_file = os.path.split(socket_path) 47 | if socket_file == "": 48 | sys.exit("malformed FN_LISTENER env var " 49 | "value: {0}".format(socket_path)) 50 | 51 | phony_socket_path = os.path.join( 52 | socket_dir, "phony" + socket_file) 53 | 54 | log.log("deleting socket files if they exist") 55 | try: 56 | os.remove(socket_path) 57 | os.remove(phony_socket_path) 58 | except OSError: 59 | pass 60 | 61 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 62 | sock.bind(phony_socket_path) 63 | 64 | rtr = router.Router() 65 | rtr.add("/call", frozenset({"POST"}), 66 | event_handler.event_handle(handle_code)) 67 | 68 | srv = app.AsyncHTTPServer(name="fdk", router=rtr) 69 | start_serving, server_forever = srv.run(sock=sock, loop=loop) 70 | 71 | try: 72 | log.log("CHMOD 666 {0}".format(phony_socket_path)) 73 | os.chmod(phony_socket_path, 0o666) 74 | log.log("phony socket permissions: {0}" 75 | .format(oct(os.stat(phony_socket_path).st_mode))) 76 | log.log("calling '.start_serving()'") 77 | start_serving() 78 | log.log("sym-linking {0} to {1}".format( 79 | socket_path, phony_socket_path)) 80 | os.symlink(os.path.basename(phony_socket_path), socket_path) 81 | log.log("socket permissions: {0}" 82 | .format(oct(os.stat(socket_path).st_mode))) 83 | log.log("starting infinite loop") 84 | 85 | except (Exception, BaseException) as ex: 86 | log.log(str(ex)) 87 | raise ex 88 | 89 | server_forever() 90 | 91 | 92 | def handle(handle_code: customer_code.Function): 93 | """ 94 | FDK entry point 95 | :param handle_code: customer's code 96 | :type handle_code: fdk.customer_code.Function 97 | :return: None 98 | """ 99 | log.log("entering handle") 100 | if not isinstance(handle_code, customer_code.Function): 101 | sys.exit( 102 | '\n\n\nWARNING!\n\n' 103 | 'Your code is not compatible the the latest FDK!\n\n' 104 | 'Update Dockerfile entry point to:\n' 105 | 'ENTRYPOINT["/python/bin/fdk", "", {0}]\n\n' 106 | 'if __name__ == "__main__":\n\tfdk.handle(handler)\n\n' 107 | 'syntax no longer supported!\n' 108 | 'Update your code as soon as possible!' 109 | '\n\n\n'.format(handle_code.__name__)) 110 | 111 | loop = asyncio.get_event_loop() 112 | 113 | format_def = os.environ.get(constants.FN_FORMAT) 114 | lsnr = os.environ.get(constants.FN_LISTENER) 115 | log.log("{0} is set, value: {1}". 116 | format(constants.FN_FORMAT, format_def)) 117 | 118 | if lsnr is None: 119 | sys.exit("{0} is not set".format(constants.FN_LISTENER)) 120 | 121 | log.log("{0} is set, value: {1}". 122 | format(constants.FN_LISTENER, lsnr)) 123 | 124 | if format_def == constants.HTTPSTREAM: 125 | start(handle_code, lsnr, loop=loop) 126 | else: 127 | sys.exit("incompatible function format!") 128 | -------------------------------------------------------------------------------- /fdk/async_http/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fnproject/fdk-python/01c04ed039a437584958816f35956b3dbd53d841/fdk/async_http/__init__.py -------------------------------------------------------------------------------- /fdk/async_http/app.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import logging 18 | 19 | from asyncio import CancelledError 20 | from traceback import format_exc 21 | 22 | from .exceptions import AsyncHTTPException 23 | from .error_handler import ErrorHandler 24 | from .request import Request 25 | from .response import HTTPResponse, StreamingHTTPResponse 26 | from .server import serve 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | class AsyncHTTPServer(object): 32 | def __init__( 33 | self, 34 | name=None, 35 | router=None, 36 | ): 37 | 38 | self.name = name 39 | self.router = router 40 | self.request_class = Request 41 | self.error_handler = ErrorHandler() 42 | self.config = {} 43 | self.debug = None 44 | self.sock = None 45 | self.is_running = False 46 | self.is_request_stream = False 47 | self.websocket_enabled = False 48 | self.websocket_tasks = set() 49 | 50 | # Register alternative method names 51 | self.go_fast = self.run 52 | 53 | async def handle_request(self, request, write_callback, stream_callback): 54 | """Take a request from the HTTP Server and return a response object 55 | to be sent back The HTTP Server only expects a response object, so 56 | exception handling must be done here 57 | :param request: HTTP Request object 58 | :param write_callback: Synchronous response function to be 59 | called with the response as the only argument 60 | :param stream_callback: Coroutine that handles streaming a 61 | StreamingHTTPResponse if produced by the handler. 62 | :return: Nothing 63 | """ 64 | # Define `response` var here to remove warnings about 65 | # allocation before assignment below. 66 | cancelled = False 67 | try: 68 | request.app = self 69 | # -------------------------------------------- # 70 | # Execute Handler 71 | # -------------------------------------------- # 72 | 73 | # Fetch handler from router 74 | handler, uri = self.router.get( 75 | request.path, request.method) 76 | 77 | request.uri_template = uri 78 | response = handler(request) 79 | logger.debug("got response from function") 80 | res = await response 81 | body = res.body 82 | headers = res.headers 83 | status = res.status 84 | response = HTTPResponse( 85 | body_bytes=body, status=status, headers=headers, 86 | ) 87 | except CancelledError: 88 | response = None 89 | cancelled = True 90 | except Exception as e: 91 | if isinstance(e, AsyncHTTPException): 92 | response = self.error_handler.default( 93 | request=request, exception=e 94 | ) 95 | elif self.debug: 96 | response = HTTPResponse( 97 | "Error while handling error: {}\nStack: {}".format( 98 | e, format_exc() 99 | ), 100 | status=502, 101 | ) 102 | else: 103 | response = HTTPResponse( 104 | "An error occurred while handling an error", status=502 105 | ) 106 | finally: 107 | if cancelled: 108 | raise CancelledError() 109 | 110 | if isinstance(response, StreamingHTTPResponse): 111 | await stream_callback(response) 112 | else: 113 | write_callback(response) 114 | 115 | def run(self, sock=None, loop=None): 116 | return serve( 117 | self.handle_request, ErrorHandler(), 118 | sock=sock, loop=loop 119 | ) 120 | -------------------------------------------------------------------------------- /fdk/async_http/error_handler.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import logging 18 | import sys 19 | 20 | from .response import html, text 21 | 22 | from traceback import extract_tb, format_exc 23 | 24 | from .exceptions import ( 25 | INTERNAL_SERVER_ERROR_HTML, 26 | TRACEBACK_BORDER, 27 | TRACEBACK_LINE_HTML, 28 | TRACEBACK_STYLE, 29 | TRACEBACK_WRAPPER_HTML, 30 | TRACEBACK_WRAPPER_INNER_HTML, 31 | AsyncHTTPException, 32 | ) 33 | 34 | logger = logging.getLogger(__name__) 35 | 36 | 37 | class ErrorHandler(object): 38 | handlers = None 39 | cached_handlers = None 40 | _missing = object() 41 | 42 | def __init__(self): 43 | self.handlers = [] 44 | self.cached_handlers = {} 45 | self.debug = False 46 | 47 | def _render_exception(self, exception): 48 | frames = extract_tb(exception.__traceback__) 49 | 50 | frame_html = [] 51 | for frame in frames: 52 | frame_html.append(TRACEBACK_LINE_HTML.format(frame)) 53 | 54 | return TRACEBACK_WRAPPER_INNER_HTML.format( 55 | exc_name=exception.__class__.__name__, 56 | exc_value=exception, 57 | frame_html="".join(frame_html), 58 | ) 59 | 60 | def _render_traceback_html(self, exception, request): 61 | exc_type, exc_value, tb = sys.exc_info() 62 | exceptions = [] 63 | 64 | while exc_value: 65 | exceptions.append(self._render_exception(exc_value)) 66 | exc_value = exc_value.__cause__ 67 | 68 | return TRACEBACK_WRAPPER_HTML.format( 69 | style=TRACEBACK_STYLE, 70 | exc_name=exception.__class__.__name__, 71 | exc_value=exception, 72 | inner_html=TRACEBACK_BORDER.join(reversed(exceptions)), 73 | path=request.path, 74 | ) 75 | 76 | def add(self, exception, handler): 77 | self.handlers.append((exception, handler)) 78 | 79 | def lookup(self, exception): 80 | handler = self.cached_handlers.get(type(exception), self._missing) 81 | if handler is self._missing: 82 | for exception_class, handler in self.handlers: 83 | if isinstance(exception, exception_class): 84 | self.cached_handlers[type(exception)] = handler 85 | return handler 86 | self.cached_handlers[type(exception)] = None 87 | handler = None 88 | return handler 89 | 90 | def response(self, request, exception): 91 | """Fetches and executes an exception handler and returns a response 92 | object 93 | 94 | :param request: Request 95 | :param exception: Exception to handle 96 | :return: Response object 97 | """ 98 | handler = self.lookup(exception) 99 | response = None 100 | try: 101 | if handler: 102 | response = handler(request, exception) 103 | if response is None: 104 | response = self.default(request, exception) 105 | except Exception: 106 | self.log(format_exc()) 107 | try: 108 | url = repr(request.url) 109 | except AttributeError: 110 | url = "unknown" 111 | response_message = ( 112 | "Exception raised in exception handler " '"%s" for uri: %s' 113 | ) 114 | logger.exception(response_message, handler.__name__, url) 115 | 116 | if self.debug: 117 | return text(response_message % (handler.__name__, url), 500) 118 | else: 119 | return text("An error occurred while handling an error", 500) 120 | return response 121 | 122 | def default(self, request, exception): 123 | logger.error(format_exc()) 124 | try: 125 | url = repr(request.url) 126 | except AttributeError: 127 | url = "unknown" 128 | 129 | response_message = ("Exception occurred while " 130 | "handling uri: {0}".format(url)) 131 | logger.error(response_message, exc_info=1) 132 | 133 | if issubclass(type(exception), AsyncHTTPException): 134 | return text( 135 | "Error: {}".format(exception), 136 | status=getattr(exception, "status_code", 500), 137 | headers=getattr(exception, "headers", dict()), 138 | ) 139 | elif self.debug: 140 | html_output = self._render_traceback_html(exception, request) 141 | 142 | return html(html_output, status=500) 143 | else: 144 | return html(INTERNAL_SERVER_ERROR_HTML, status=500) 145 | -------------------------------------------------------------------------------- /fdk/async_http/exceptions.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | """Defines basics of HTTP standard.""" 18 | 19 | # TODO: use native python tools to build this map at the runtime 20 | STATUS_CODES = { 21 | 100: b"Continue", 22 | 101: b"Switching Protocols", 23 | 102: b"Processing", 24 | 200: b"OK", 25 | 201: b"Created", 26 | 202: b"Accepted", 27 | 203: b"Non-Authoritative Information", 28 | 204: b"No Content", 29 | 205: b"Reset Content", 30 | 206: b"Partial Content", 31 | 207: b"Multi-Status", 32 | 208: b"Already Reported", 33 | 226: b"IM Used", 34 | 300: b"Multiple Choices", 35 | 301: b"Moved Permanently", 36 | 302: b"Found", 37 | 303: b"See Other", 38 | 304: b"Not Modified", 39 | 305: b"Use Proxy", 40 | 307: b"Temporary Redirect", 41 | 308: b"Permanent Redirect", 42 | 400: b"Bad Request", 43 | 401: b"Unauthorized", 44 | 402: b"Payment Required", 45 | 403: b"Forbidden", 46 | 404: b"Not Found", 47 | 405: b"Method Not Allowed", 48 | 406: b"Not Acceptable", 49 | 407: b"Proxy Authentication Required", 50 | 408: b"Request Timeout", 51 | 409: b"Conflict", 52 | 410: b"Gone", 53 | 411: b"Length Required", 54 | 412: b"Precondition Failed", 55 | 413: b"Request Entity Too Large", 56 | 414: b"Request-URI Too Long", 57 | 415: b"Unsupported Media Type", 58 | 416: b"Requested Range Not Satisfiable", 59 | 417: b"Expectation Failed", 60 | 418: b"I'm a teapot", 61 | 422: b"Unprocessable Entity", 62 | 423: b"Locked", 63 | 424: b"Failed Dependency", 64 | 426: b"Upgrade Required", 65 | 428: b"Precondition Required", 66 | 429: b"Too Many Requests", 67 | 431: b"Request Header Fields Too Large", 68 | 451: b"Unavailable For Legal Reasons", 69 | 500: b"Internal Server Error", 70 | 501: b"Not Implemented", 71 | 502: b"Bad Gateway", 72 | 503: b"Service Unavailable", 73 | 504: b"Gateway Timeout", 74 | 505: b"HTTP Version Not Supported", 75 | 506: b"Variant Also Negotiates", 76 | 507: b"Insufficient Storage", 77 | 508: b"Loop Detected", 78 | 510: b"Not Extended", 79 | 511: b"Network Authentication Required", 80 | } 81 | 82 | # According to https://tools.ietf.org/html/rfc2616#section-7.1 83 | _ENTITY_HEADERS = frozenset( 84 | [ 85 | "allow", 86 | "content-encoding", 87 | "content-language", 88 | "content-length", 89 | "content-location", 90 | "content-md5", 91 | "content-range", 92 | "content-type", 93 | "expires", 94 | "last-modified", 95 | "extension-header", 96 | ] 97 | ) 98 | 99 | # According to https://tools.ietf.org/html/rfc2616#section-13.5.1 100 | _HOP_BY_HOP_HEADERS = frozenset( 101 | [ 102 | "connection", 103 | "keep-alive", 104 | "proxy-authenticate", 105 | "proxy-authorization", 106 | "te", 107 | "trailers", 108 | "transfer-encoding", 109 | "upgrade", 110 | ] 111 | ) 112 | 113 | 114 | def has_message_body(status): 115 | """ 116 | According to the following RFC message body and length SHOULD NOT 117 | be included in responses status 1XX, 204 and 304. 118 | https://tools.ietf.org/html/rfc2616#section-4.4 119 | https://tools.ietf.org/html/rfc2616#section-4.3 120 | """ 121 | return status not in (204, 304) and not (100 <= status < 200) 122 | 123 | 124 | def is_entity_header(header): 125 | """Checks if the given header is an Entity Header""" 126 | return header.lower() in _ENTITY_HEADERS 127 | 128 | 129 | def is_hop_by_hop_header(header): 130 | """Checks if the given header is a Hop By Hop header""" 131 | return header.lower() in _HOP_BY_HOP_HEADERS 132 | 133 | 134 | def remove_entity_headers(headers, allowed=("content-location", "expires")): 135 | """ 136 | Removes all the entity headers present in the headers given. 137 | According to RFC 2616 Section 10.3.5, 138 | Content-Location and Expires are allowed as for the 139 | "strong cache validator". 140 | https://tools.ietf.org/html/rfc2616#section-10.3.5 141 | 142 | returns the headers without the entity headers 143 | """ 144 | allowed = set([h.lower() for h in allowed]) 145 | headers = { 146 | header: value 147 | for header, value in headers.items() 148 | if not is_entity_header(header) or header.lower() in allowed 149 | } 150 | return headers 151 | 152 | 153 | TRACEBACK_STYLE = """ 154 | 214 | """ 215 | 216 | TRACEBACK_WRAPPER_HTML = """ 217 | 218 | 219 | {style} 220 | 221 | 222 | {inner_html} 223 |
224 |

225 | {exc_name}: {exc_value} 226 | while handling path {path} 227 |

228 |
229 | 230 | 231 | """ 232 | 233 | TRACEBACK_WRAPPER_INNER_HTML = """ 234 |

{exc_name}

235 |

{exc_value}

236 |
237 |

Traceback (most recent call last):

238 | {frame_html} 239 |
240 | """ 241 | 242 | TRACEBACK_BORDER = """ 243 |
244 | 245 | The above exception was the direct cause of the 246 | following exception: 247 | 248 |
249 | """ 250 | 251 | TRACEBACK_LINE_HTML = """ 252 |
253 |

254 | File {0.filename}, line {0.lineno}, 255 | in {0.name} 256 |

257 |

{0.line}

258 |
259 | """ 260 | 261 | INTERNAL_SERVER_ERROR_HTML = """ 262 |

Internal Server Error

263 |

264 | The server encountered an internal error and cannot complete 265 | your request. 266 |

267 | """ 268 | 269 | 270 | _excs = {} 271 | 272 | 273 | def add_status_code(code): 274 | """ 275 | Decorator used for adding exceptions to _sanic_exceptions. 276 | """ 277 | 278 | def class_decorator(cls): 279 | cls.status_code = code 280 | _excs[code] = cls 281 | return cls 282 | 283 | return class_decorator 284 | 285 | 286 | class AsyncHTTPException(Exception): 287 | def __init__(self, message, status_code=None): 288 | super().__init__(message) 289 | 290 | if status_code is not None: 291 | self.status_code = status_code 292 | 293 | 294 | @add_status_code(404) 295 | class NotFound(AsyncHTTPException): 296 | pass 297 | 298 | 299 | @add_status_code(400) 300 | class InvalidUsage(AsyncHTTPException): 301 | pass 302 | 303 | 304 | @add_status_code(405) 305 | class MethodNotSupported(AsyncHTTPException): 306 | def __init__(self, message, method, allowed_methods): 307 | super().__init__(message) 308 | self.headers = dict() 309 | self.headers["Allow"] = ", ".join(allowed_methods) 310 | if method in ["HEAD", "PATCH", "PUT", "DELETE"]: 311 | self.headers["Content-Length"] = 0 312 | 313 | 314 | @add_status_code(500) 315 | class ServerError(AsyncHTTPException): 316 | pass 317 | 318 | 319 | @add_status_code(503) 320 | class ServiceUnavailable(AsyncHTTPException): 321 | """The server is currently unavailable (because it is overloaded or 322 | down for maintenance). Generally, this is a temporary state.""" 323 | 324 | pass 325 | 326 | 327 | class URLBuildError(ServerError): 328 | pass 329 | 330 | 331 | class FileNotFound(NotFound): 332 | def __init__(self, message, path, relative_url): 333 | super().__init__(message) 334 | self.path = path 335 | self.relative_url = relative_url 336 | 337 | 338 | @add_status_code(408) 339 | class RequestTimeout(AsyncHTTPException): 340 | """The Web server (running the Web site) thinks that there has been too 341 | long an interval of time between 1) the establishment of an IP 342 | connection (socket) between the client and the server and 343 | 2) the receipt of any data on that socket, so the server has dropped 344 | the connection. The socket connection has actually been lost - the Web 345 | server has 'timed out' on that particular socket connection. 346 | """ 347 | 348 | pass 349 | 350 | 351 | @add_status_code(413) 352 | class PayloadTooLarge(AsyncHTTPException): 353 | pass 354 | 355 | 356 | class HeaderNotFound(InvalidUsage): 357 | pass 358 | 359 | 360 | @add_status_code(416) 361 | class ContentRangeError(AsyncHTTPException): 362 | def __init__(self, message, content_range): 363 | super().__init__(message) 364 | self.headers = { 365 | "Content-Type": "text/plain", 366 | "Content-Range": "bytes */%s" % (content_range.total,), 367 | } 368 | 369 | 370 | @add_status_code(403) 371 | class Forbidden(AsyncHTTPException): 372 | pass 373 | 374 | 375 | class InvalidRangeType(ContentRangeError): 376 | pass 377 | 378 | 379 | class PyFileError(Exception): 380 | def __init__(self, file): 381 | super().__init__("could not execute config file %s", file) 382 | 383 | 384 | @add_status_code(401) 385 | class Unauthorized(AsyncHTTPException): 386 | """ 387 | Unauthorized exception (401 HTTP status code). 388 | 389 | :param message: Message describing the exception. 390 | :param status_code: HTTP Status code. 391 | :param scheme: Name of the authentication scheme to be used. 392 | 393 | When present, kwargs is used to complete the WWW-Authentication header. 394 | 395 | Examples:: 396 | 397 | # With a Basic auth-scheme, realm MUST be present: 398 | raise Unauthorized("Auth required.", 399 | scheme="Basic", 400 | realm="Restricted Area") 401 | 402 | # With a Digest auth-scheme, things are a bit more complicated: 403 | raise Unauthorized("Auth required.", 404 | scheme="Digest", 405 | realm="Restricted Area", 406 | qop="auth, auth-int", 407 | algorithm="MD5", 408 | nonce="abcdef", 409 | opaque="zyxwvu") 410 | 411 | # With a Bearer auth-scheme, realm is optional so you can write: 412 | raise Unauthorized("Auth required.", scheme="Bearer") 413 | 414 | # or, if you want to specify the realm: 415 | raise Unauthorized("Auth required.", 416 | scheme="Bearer", 417 | realm="Restricted Area") 418 | """ 419 | 420 | def __init__(self, message, status_code=None, scheme=None, **kwargs): 421 | super().__init__(message, status_code) 422 | 423 | # if auth-scheme is specified, set "WWW-Authenticate" header 424 | if scheme is not None: 425 | values = ['{!s}="{!s}"'.format(k, v) for k, v in kwargs.items()] 426 | challenge = ", ".join(values) 427 | 428 | self.headers = { 429 | "WWW-Authenticate": "{} {}".format(scheme, challenge).rstrip() 430 | } 431 | 432 | 433 | def abort(status_code, message=None): 434 | """ 435 | Raise an exception based on AsyncHTTPException. Returns the HTTP response 436 | message appropriate for the given status code, unless provided. 437 | 438 | :param status_code: The HTTP status code to return. 439 | :param message: The HTTP response body. Defaults to the messages 440 | in response.py for the given status code. 441 | """ 442 | if message is None: 443 | message = STATUS_CODES.get(status_code) 444 | # These are stored as bytes in the STATUS_CODES dict 445 | message = message.decode("utf8") 446 | exc = _excs.get(status_code, AsyncHTTPException) 447 | raise exc(message=message, status_code=status_code) 448 | -------------------------------------------------------------------------------- /fdk/async_http/protocol.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import asyncio 18 | import sys 19 | import traceback 20 | 21 | from httptools import HttpRequestParser 22 | from httptools.parser.errors import HttpParserError 23 | 24 | from .exceptions import ( 25 | InvalidUsage, 26 | PayloadTooLarge, 27 | RequestTimeout, 28 | ServerError, 29 | ServiceUnavailable, 30 | ) 31 | 32 | from .request import Request, StreamBuffer 33 | from .response import HTTPResponse 34 | 35 | import logging 36 | logger = logging.getLogger(__name__) 37 | 38 | try: 39 | import uvloop 40 | 41 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 42 | except ImportError: 43 | pass 44 | 45 | 46 | current_time = None 47 | 48 | 49 | class HttpProtocol(asyncio.Protocol): 50 | """ 51 | This class provides a basic HTTP implementation. 52 | """ 53 | 54 | __slots__ = ( 55 | # event loop, connection 56 | "loop", 57 | "transport", 58 | "connections", 59 | "signal", 60 | # request params 61 | "parser", 62 | "request", 63 | "url", 64 | "headers", 65 | # request config 66 | "request_handler", 67 | "request_timeout", 68 | "response_timeout", 69 | "keep_alive_timeout", 70 | "request_max_size", 71 | "request_buffer_queue_size", 72 | "request_class", 73 | "is_request_stream", 74 | "router", 75 | "error_handler", 76 | # enable or disable access log purpose 77 | "access_log", 78 | # connection management 79 | "_total_request_size", 80 | "_request_timeout_handler", 81 | "_response_timeout_handler", 82 | "_keep_alive_timeout_handler", 83 | "_last_request_time", 84 | "_last_response_time", 85 | "_is_stream_handler", 86 | "_not_paused", 87 | "_request_handler_task", 88 | "_request_stream_task", 89 | "_keep_alive", 90 | "_header_fragment", 91 | "state", 92 | "_debug", 93 | ) 94 | 95 | def __init__( 96 | self, 97 | *, 98 | loop, 99 | request_handler, 100 | error_handler, 101 | signal=None, 102 | connections=None, 103 | request_timeout=60, 104 | response_timeout=60, 105 | keep_alive_timeout=5, 106 | request_max_size=None, 107 | request_buffer_queue_size=100, 108 | request_class=None, 109 | access_log=True, 110 | keep_alive=True, 111 | is_request_stream=False, 112 | router=None, 113 | state=None, 114 | debug=False, 115 | **kwargs 116 | ): 117 | self.loop = loop 118 | self.transport = None 119 | self.request = None 120 | self.parser = None 121 | self.url = None 122 | self.headers = None 123 | self.router = router 124 | self.signal = signal 125 | self.access_log = access_log 126 | self.connections = connections or set() 127 | self.request_handler = request_handler 128 | self.error_handler = error_handler 129 | self.request_timeout = request_timeout 130 | self.request_buffer_queue_size = request_buffer_queue_size 131 | self.response_timeout = response_timeout 132 | self.keep_alive_timeout = keep_alive_timeout 133 | self.request_max_size = request_max_size 134 | self.request_class = request_class or Request 135 | self.is_request_stream = is_request_stream 136 | self._is_stream_handler = False 137 | if sys.version_info >= (3, 10): 138 | self._not_paused = asyncio.Event() 139 | else: 140 | self._not_paused = asyncio.Event(loop=loop) 141 | self._total_request_size = 0 142 | self._request_timeout_handler = None 143 | self._response_timeout_handler = None 144 | self._keep_alive_timeout_handler = None 145 | self._last_request_time = None 146 | self._last_response_time = None 147 | self._request_handler_task = None 148 | self._request_stream_task = None 149 | self._keep_alive = keep_alive 150 | self._header_fragment = b"" 151 | self.state = state if state else {} 152 | if "requests_count" not in self.state: 153 | self.state["requests_count"] = 0 154 | self._debug = debug 155 | self._not_paused.set() 156 | 157 | @property 158 | def keep_alive(self): 159 | """ 160 | Check if the connection needs to be kept alive based on the params 161 | attached to the `_keep_alive` attribute, :attr:`Signal.stopped` 162 | and :func:`HttpProtocol.parser.should_keep_alive` 163 | 164 | :return: ``True`` if connection is to be kept alive ``False`` else 165 | """ 166 | return ( 167 | self._keep_alive 168 | and not self.signal.stopped 169 | and self.parser.should_keep_alive() 170 | ) 171 | 172 | # -------------------------------------------- # 173 | # Connection 174 | # -------------------------------------------- # 175 | 176 | def connection_made(self, transport): 177 | self.connections.add(self) 178 | self._request_timeout_handler = self.loop.call_later( 179 | self.request_timeout, self.request_timeout_callback 180 | ) 181 | self.transport = transport 182 | self._last_request_time = current_time 183 | 184 | def connection_lost(self, exc): 185 | self.connections.discard(self) 186 | if self._request_handler_task: 187 | self._request_handler_task.cancel() 188 | if self._request_stream_task: 189 | self._request_stream_task.cancel() 190 | if self._request_timeout_handler: 191 | self._request_timeout_handler.cancel() 192 | if self._response_timeout_handler: 193 | self._response_timeout_handler.cancel() 194 | if self._keep_alive_timeout_handler: 195 | self._keep_alive_timeout_handler.cancel() 196 | 197 | def pause_writing(self): 198 | self._not_paused.clear() 199 | 200 | def resume_writing(self): 201 | self._not_paused.set() 202 | 203 | def request_timeout_callback(self): 204 | # See the docstring in the RequestTimeout exception, to see 205 | # exactly what this timeout is checking for. 206 | # Check if elapsed time since request initiated exceeds our 207 | # configured maximum request timeout value 208 | time_elapsed = current_time - self._last_request_time 209 | if time_elapsed < self.request_timeout: 210 | time_left = self.request_timeout - time_elapsed 211 | self._request_timeout_handler = self.loop.call_later( 212 | time_left, self.request_timeout_callback 213 | ) 214 | else: 215 | if self._request_stream_task: 216 | self._request_stream_task.cancel() 217 | if self._request_handler_task: 218 | self._request_handler_task.cancel() 219 | self.write_error(RequestTimeout("Request Timeout")) 220 | 221 | def response_timeout_callback(self): 222 | # Check if elapsed time since response was initiated exceeds our 223 | # configured maximum request timeout value 224 | time_elapsed = current_time - self._last_request_time 225 | if time_elapsed < self.response_timeout: 226 | time_left = self.response_timeout - time_elapsed 227 | self._response_timeout_handler = self.loop.call_later( 228 | time_left, self.response_timeout_callback 229 | ) 230 | else: 231 | if self._request_stream_task: 232 | self._request_stream_task.cancel() 233 | if self._request_handler_task: 234 | self._request_handler_task.cancel() 235 | self.write_error(ServiceUnavailable("Response Timeout")) 236 | 237 | def keep_alive_time_left(self): 238 | """ 239 | Return keep alive time remaining, zero if no response written yet. 240 | """ 241 | if self._last_response_time and current_time: 242 | time_elapsed = current_time - self._last_response_time 243 | return self.keep_alive_timeout - time_elapsed 244 | return 0 245 | 246 | def keep_alive_timeout_callback(self): 247 | """ 248 | Check if elapsed time since last response exceeds our configured 249 | maximum keep alive timeout value and if so, close the transport 250 | pipe and let the response writer handle the error. 251 | 252 | :return: None 253 | """ 254 | time_left = self.keep_alive_time_left() 255 | if time_left >= 0: 256 | self._keep_alive_timeout_handler = self.loop.call_later( 257 | time_left, self.keep_alive_timeout_callback 258 | ) 259 | else: 260 | logger.debug("KeepAlive Timeout. Closing connection.") 261 | self.transport.close() 262 | self.transport = None 263 | 264 | # -------------------------------------------- # 265 | # Parsing 266 | # -------------------------------------------- # 267 | 268 | def data_received(self, data): 269 | # Check for the request itself getting too large and exceeding 270 | # memory limits 271 | self._total_request_size += len(data) 272 | # if self._total_request_size > self.request_max_size: 273 | # self.write_error(PayloadTooLarge("Payload Too Large")) 274 | 275 | # Create parser if this is the first time we're receiving data 276 | if self.parser is None: 277 | assert self.request is None 278 | self.headers = [] 279 | self.parser = HttpRequestParser(self) 280 | 281 | # requests count 282 | self.state["requests_count"] = self.state["requests_count"] + 1 283 | 284 | # Parse request chunk or close connection 285 | try: 286 | self.parser.feed_data(data) 287 | except HttpParserError: 288 | message = "Bad Request" 289 | if self._debug: 290 | message += "\n" + traceback.format_exc() 291 | self.write_error(InvalidUsage(message)) 292 | 293 | def on_url(self, url): 294 | if not self.url: 295 | self.url = url 296 | else: 297 | self.url += url 298 | 299 | def on_header(self, name, value): 300 | self._header_fragment += name 301 | 302 | if value is not None: 303 | if ( 304 | self._header_fragment == b"Content-Length" 305 | and int(value) > self.request_max_size 306 | ): 307 | self.write_error(PayloadTooLarge("Payload Too Large")) 308 | try: 309 | value = value.decode() 310 | except UnicodeDecodeError: 311 | value = value.decode("latin_1") 312 | self.headers.append( 313 | (self._header_fragment.decode().casefold(), value) 314 | ) 315 | 316 | self._header_fragment = b"" 317 | 318 | def on_headers_complete(self): 319 | self.request = self.request_class( 320 | url_bytes=self.url, 321 | headers=dict(self.headers), 322 | version=self.parser.get_http_version(), 323 | method=self.parser.get_method().decode(), 324 | transport=self.transport, 325 | ) 326 | # Remove any existing KeepAlive handler here, 327 | # It will be recreated if required on the new request. 328 | if self._keep_alive_timeout_handler: 329 | self._keep_alive_timeout_handler.cancel() 330 | self._keep_alive_timeout_handler = None 331 | if self.is_request_stream: 332 | self._is_stream_handler = self.router.is_stream_handler( 333 | self.request 334 | ) 335 | if self._is_stream_handler: 336 | self.request.stream = StreamBuffer( 337 | self.request_buffer_queue_size 338 | ) 339 | self.execute_request_handler() 340 | 341 | def on_body(self, body): 342 | if self.is_request_stream and self._is_stream_handler: 343 | self._request_stream_task = self.loop.create_task( 344 | self.body_append(body) 345 | ) 346 | else: 347 | self.request.body_push(body) 348 | 349 | async def body_append(self, body): 350 | if self.request.stream.is_full(): 351 | self.transport.pause_reading() 352 | await self.request.stream.put(body) 353 | self.transport.resume_reading() 354 | else: 355 | await self.request.stream.put(body) 356 | 357 | def on_message_complete(self): 358 | # Entire request (headers and whole body) is received. 359 | # We can cancel and remove the request timeout handler now. 360 | if self._request_timeout_handler: 361 | self._request_timeout_handler.cancel() 362 | self._request_timeout_handler = None 363 | if self.is_request_stream and self._is_stream_handler: 364 | self._request_stream_task = self.loop.create_task( 365 | self.request.stream.put(None) 366 | ) 367 | return 368 | self.request.body_finish() 369 | self.execute_request_handler() 370 | 371 | def execute_request_handler(self): 372 | """ 373 | Invoke the request handler defined by the 374 | 375 | :return: None 376 | """ 377 | self._response_timeout_handler = self.loop.call_later( 378 | self.response_timeout, self.response_timeout_callback 379 | ) 380 | self._last_request_time = current_time 381 | self._request_handler_task = self.loop.create_task( 382 | self.request_handler( 383 | self.request, self.write_response, self.stream_response 384 | ) 385 | ) 386 | 387 | # -------------------------------------------- # 388 | # Responding 389 | # -------------------------------------------- # 390 | def log_response(self, response): 391 | """ 392 | Helper method provided to enable the logging of responses in case if 393 | the :attr:`HttpProtocol.access_log` is enabled. 394 | 395 | :param response: Response generated for the current request 396 | 397 | :type response: :class:`async_http.response.HTTPResponse` or 398 | :class:`async_http.response.StreamingHTTPResponse` 399 | 400 | :return: None 401 | """ 402 | if self.access_log: 403 | extra = {"status": getattr(response, "status", 0)} 404 | 405 | if isinstance(response, HTTPResponse): 406 | extra["byte"] = len(response.body) 407 | else: 408 | extra["byte"] = -1 409 | 410 | def write_response(self, response): 411 | """ 412 | Writes response content synchronously to the transport. 413 | """ 414 | if self._response_timeout_handler: 415 | self._response_timeout_handler.cancel() 416 | self._response_timeout_handler = None 417 | try: 418 | keep_alive = self.keep_alive 419 | self.transport.write( 420 | response.output( 421 | self.request.version, keep_alive, self.keep_alive_timeout 422 | ) 423 | ) 424 | self.log_response(response) 425 | except AttributeError: 426 | logger.error( 427 | "Invalid response object for url %s, " 428 | "Expected Type: HTTPResponse, Actual Type: %s" % 429 | (self.url, type(response)), exc_info=1 430 | ) 431 | self.write_error(ServerError("Invalid response type")) 432 | except RuntimeError: 433 | if self._debug: 434 | logger.error( 435 | "Connection lost before response written @ %s" % 436 | self.request.ip, exc_info=1 437 | ) 438 | keep_alive = False 439 | except Exception as e: 440 | self.bail_out( 441 | "Writing response failed, connection closed {}".format(repr(e)) 442 | ) 443 | finally: 444 | if not keep_alive: 445 | self.transport.close() 446 | self.transport = None 447 | else: 448 | self._last_response_time = current_time 449 | self._keep_alive_timeout_handler = self.loop.call_later( 450 | self.keep_alive_timeout, self.keep_alive_timeout_callback 451 | ) 452 | self.cleanup() 453 | 454 | async def drain(self): 455 | await self._not_paused.wait() 456 | 457 | def push_data(self, data): 458 | self.transport.write(data) 459 | 460 | async def stream_response(self, response): 461 | """ 462 | Streams a response to the client asynchronously. Attaches 463 | the transport to the response so the response consumer can 464 | write to the response as needed. 465 | """ 466 | if self._response_timeout_handler: 467 | self._response_timeout_handler.cancel() 468 | self._response_timeout_handler = None 469 | 470 | try: 471 | keep_alive = self.keep_alive 472 | response.protocol = self 473 | await response.stream( 474 | self.request.version, keep_alive, self.keep_alive_timeout 475 | ) 476 | self.log_response(response) 477 | except AttributeError: 478 | logger.error( 479 | "Invalid response object for url %s, " 480 | "Expected Type: HTTPResponse, Actual Type: %s" % 481 | (self.url, type(response)), exc_info=1 482 | ) 483 | self.write_error(ServerError("Invalid response type")) 484 | except RuntimeError: 485 | if self._debug: 486 | logger.error( 487 | "Connection lost before response written @ %s" % 488 | self.request.ip, exc_info=1 489 | ) 490 | keep_alive = False 491 | except Exception as e: 492 | self.bail_out( 493 | "Writing response failed, connection closed {}".format(repr(e)) 494 | ) 495 | finally: 496 | if not keep_alive: 497 | self.transport.close() 498 | self.transport = None 499 | else: 500 | self._last_response_time = current_time 501 | self._keep_alive_timeout_handler = self.loop.call_later( 502 | self.keep_alive_timeout, self.keep_alive_timeout_callback 503 | ) 504 | self.cleanup() 505 | 506 | def write_error(self, exception): 507 | # An error _is_ a response. 508 | # Don't throw a response timeout, when a response _is_ given. 509 | if self._response_timeout_handler: 510 | self._response_timeout_handler.cancel() 511 | self._response_timeout_handler = None 512 | response = None 513 | try: 514 | response = self.error_handler.response(self.request, exception) 515 | version = self.request.version if self.request else "1.1" 516 | self.transport.write(response.output(version)) 517 | except RuntimeError: 518 | if self._debug: 519 | logger.error( 520 | "Connection lost before error written @ %s" % 521 | (self.request.ip if self.request else "Unknown"), 522 | exc_info=1 523 | ) 524 | except Exception as e: 525 | self.bail_out( 526 | "Writing error failed, connection closed {}".format(repr(e)), 527 | from_error=True, 528 | ) 529 | finally: 530 | if self.parser and ( 531 | self.keep_alive or getattr(response, "status", 0) == 408 532 | ): 533 | self.log_response(response) 534 | try: 535 | self.transport.close() 536 | except AttributeError: 537 | logger.error("Connection lost before server could close it.") 538 | 539 | def bail_out(self, message, from_error=False): 540 | """ 541 | In case if the transport pipes are closed and the app encounters 542 | an error while writing data to the transport pipe, we log the error 543 | with proper details. 544 | 545 | :param message: Error message to display 546 | :param from_error: If the bail out was invoked while handling an 547 | exception scenario. 548 | 549 | :type message: str 550 | :type from_error: bool 551 | 552 | :return: None 553 | """ 554 | if from_error or self.transport.is_closing(): 555 | logger.error( 556 | "Transport closed @ %s and exception " 557 | "experienced during error handling" % 558 | self.transport.get_extra_info("peername"), 559 | exc_info=1 560 | ) 561 | else: 562 | self.write_error(ServerError(message)) 563 | logger.error(message) 564 | 565 | def cleanup(self): 566 | """This is called when KeepAlive feature is used, 567 | it resets the connection in order for it to be able 568 | to handle receiving another request on the same connection.""" 569 | self.parser = None 570 | self.request = None 571 | self.url = None 572 | self.headers = None 573 | self._request_handler_task = None 574 | self._request_stream_task = None 575 | self._total_request_size = 0 576 | self._is_stream_handler = False 577 | 578 | def close_if_idle(self): 579 | """Close the connection if a request is not being sent or received 580 | 581 | :return: boolean - True if closed, false if staying open 582 | """ 583 | if not self.parser: 584 | self.transport.close() 585 | return True 586 | return False 587 | 588 | def close(self): 589 | """ 590 | Force close the connection. 591 | """ 592 | if self.transport is not None: 593 | self.transport.close() 594 | self.transport = None 595 | -------------------------------------------------------------------------------- /fdk/async_http/request.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import asyncio 18 | import logging 19 | 20 | from httptools import parse_url 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream" 26 | 27 | 28 | # HTTP/1.1: https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7.2.1 29 | # > If the media type remains unknown, the recipient SHOULD treat it 30 | # > as type "application/octet-stream" 31 | 32 | 33 | class RequestParameters(dict): 34 | """Hosts a dict with lists as values where get returns the first 35 | value of the list and getlist returns the whole shebang 36 | """ 37 | 38 | def get(self, name, default=None): 39 | """Return the first value, either the default or actual""" 40 | return super().get(name, [default])[0] 41 | 42 | def getlist(self, name, default=None): 43 | """Return the entire list""" 44 | return super().get(name, default) 45 | 46 | 47 | class StreamBuffer(object): 48 | def __init__(self, buffer_size=100): 49 | self._queue = asyncio.Queue(buffer_size) 50 | 51 | async def read(self): 52 | """ Stop reading when gets None """ 53 | payload = await self._queue.get() 54 | self._queue.task_done() 55 | return payload 56 | 57 | async def put(self, payload): 58 | await self._queue.put(payload) 59 | 60 | def is_full(self): 61 | return self._queue.full() 62 | 63 | 64 | class Request(dict): 65 | """Properties of an HTTP request such as URL, headers, etc.""" 66 | 67 | __slots__ = ( 68 | "__weakref__", 69 | "_cookies", 70 | "_ip", 71 | "_parsed_url", 72 | "_port", 73 | "_remote_addr", 74 | "_socket", 75 | "app", 76 | "body", 77 | "endpoint", 78 | "headers", 79 | "method", 80 | "parsed_args", 81 | "parsed_files", 82 | "parsed_form", 83 | "parsed_json", 84 | "raw_url", 85 | "stream", 86 | "transport", 87 | "uri_template", 88 | "version", 89 | ) 90 | 91 | def __init__(self, url_bytes, headers, version, method, transport): 92 | self.raw_url = url_bytes 93 | # TODO: Content-Encoding detection 94 | self._parsed_url = parse_url(url_bytes) 95 | self.app = None 96 | 97 | self.headers = headers 98 | self.version = version 99 | self.method = method 100 | self.transport = transport 101 | 102 | # Init but do not inhale 103 | self.body_init() 104 | self.parsed_json = None 105 | self.parsed_form = None 106 | self.parsed_files = None 107 | self.parsed_args = None 108 | self.uri_template = None 109 | self._cookies = None 110 | self.stream = None 111 | self.endpoint = None 112 | 113 | def __repr__(self): 114 | if self.method is None or not self.path: 115 | return "<{0}>".format(self.__class__.__name__) 116 | return "<{0}: {1} {2}>".format( 117 | self.__class__.__name__, self.method, self.path 118 | ) 119 | 120 | def __bool__(self): 121 | if self.transport: 122 | return True 123 | return False 124 | 125 | def body_init(self): 126 | self.body = [] 127 | 128 | def body_push(self, data): 129 | self.body.append(data) 130 | 131 | def body_finish(self): 132 | self.body = b"".join(self.body) 133 | 134 | @property 135 | def token(self): 136 | """Attempt to return the auth header token. 137 | 138 | :return: token related to request 139 | """ 140 | prefixes = ("Bearer", "Token") 141 | auth_header = self.headers.get("Authorization") 142 | 143 | if auth_header is not None: 144 | for prefix in prefixes: 145 | if prefix in auth_header: 146 | return auth_header.partition(prefix)[-1].strip() 147 | 148 | return auth_header 149 | 150 | @property 151 | def content_type(self): 152 | return self.headers.get("Content-Type", DEFAULT_HTTP_CONTENT_TYPE) 153 | 154 | 155 | @property 156 | def path(self): 157 | return self._parsed_url.path.decode("utf-8") 158 | 159 | @property 160 | def query_string(self): 161 | if self._parsed_url.query: 162 | return self._parsed_url.query.decode("utf-8") 163 | else: 164 | return "" 165 | -------------------------------------------------------------------------------- /fdk/async_http/response.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from functools import partial 18 | from urllib.parse import quote_plus 19 | 20 | from .exceptions import ( 21 | STATUS_CODES, has_message_body, remove_entity_headers 22 | ) 23 | 24 | from json import dumps 25 | 26 | 27 | json_dumps = partial(dumps, separators=(",", ":")) 28 | 29 | 30 | class BaseHTTPResponse(object): 31 | 32 | def _encode_body(self, data): 33 | try: 34 | # Try to encode it regularly 35 | return data.encode() 36 | except AttributeError: 37 | # Convert it to a str if you can't 38 | return str(data).encode() 39 | 40 | def _parse_headers(self): 41 | headers = b"" 42 | for name, value in self.headers.items(): 43 | try: 44 | headers += b"%b: %b\r\n" % ( 45 | name.encode(), 46 | value.encode("utf-8"), 47 | ) 48 | except AttributeError: 49 | headers += b"%b: %b\r\n" % ( 50 | str(name).encode(), 51 | str(value).encode("utf-8"), 52 | ) 53 | 54 | return headers 55 | 56 | 57 | class StreamingHTTPResponse(BaseHTTPResponse): 58 | __slots__ = ( 59 | "protocol", 60 | "streaming_fn", 61 | "status", 62 | "content_type", 63 | "headers", 64 | "_cookies", 65 | ) 66 | 67 | def __init__( 68 | self, streaming_fn, status=200, headers=None, content_type="text/plain" 69 | ): 70 | self.content_type = content_type 71 | self.streaming_fn = streaming_fn 72 | self.status = status 73 | self.headers = dict(headers or {}) 74 | self._cookies = None 75 | 76 | async def write(self, data): 77 | """Writes a chunk of data to the streaming response. 78 | 79 | :param data: bytes-ish data to be written. 80 | """ 81 | if type(data) != bytes: 82 | data = self._encode_body(data) 83 | 84 | self.protocol.push_data(b"%x\r\n%b\r\n" % (len(data), data)) 85 | await self.protocol.drain() 86 | 87 | async def stream( 88 | self, version="1.1", keep_alive=False, keep_alive_timeout=None 89 | ): 90 | """Streams headers, runs the `streaming_fn` callback that writes 91 | content to the response body, then finalizes the response body. 92 | """ 93 | headers = self.get_headers( 94 | version, 95 | keep_alive=keep_alive, 96 | keep_alive_timeout=keep_alive_timeout, 97 | ) 98 | self.protocol.push_data(headers) 99 | await self.protocol.drain() 100 | await self.streaming_fn(self) 101 | self.protocol.push_data(b"0\r\n\r\n") 102 | # no need to await drain here after this write, because it is the 103 | # very last thing we write and nothing needs to wait for it. 104 | 105 | def get_headers( 106 | self, version="1.1", keep_alive=False, keep_alive_timeout=None 107 | ): 108 | # This is all returned in a kind-of funky way 109 | # We tried to make this as fast as possible in pure python 110 | timeout_header = b"" 111 | if keep_alive and keep_alive_timeout is not None: 112 | timeout_header = b"Keep-Alive: %d\r\n" % keep_alive_timeout 113 | 114 | self.headers["Transfer-Encoding"] = "chunked" 115 | self.headers.pop("Content-Length", None) 116 | self.headers["Content-Type"] = self.headers.get( 117 | "Content-Type", self.content_type 118 | ) 119 | 120 | headers = self._parse_headers() 121 | 122 | if self.status == 200: 123 | status = b"OK" 124 | else: 125 | status = STATUS_CODES.get(self.status) 126 | 127 | return (b"HTTP/%b %d %b\r\n" b"%b" b"%b\r\n") % ( 128 | version.encode(), 129 | self.status, 130 | status, 131 | timeout_header, 132 | headers, 133 | ) 134 | 135 | 136 | # CaseInsensitiveDict for headers. TODO should we let users use it in fdk too? 137 | class CaseInsensitiveDict(dict): 138 | @classmethod 139 | def _k(cls, key): 140 | return key.lower() if isinstance(key, str) else key 141 | 142 | def __init__(self, *args, **kwargs): 143 | super(CaseInsensitiveDict, self).__init__(*args, **kwargs) 144 | self._convert_keys() 145 | 146 | def __getitem__(self, key): 147 | return super(CaseInsensitiveDict, self).__getitem__( 148 | self.__class__._k(key)) 149 | 150 | def __setitem__(self, key, value): 151 | super(CaseInsensitiveDict, self).__setitem__( 152 | self.__class__._k(key), value) 153 | 154 | def __delitem__(self, key): 155 | return super(CaseInsensitiveDict, self).__delitem__( 156 | self.__class__._k(key)) 157 | 158 | def __contains__(self, key): 159 | return super(CaseInsensitiveDict, self).__contains__( 160 | self.__class__._k(key)) 161 | 162 | # DEPRECATED 163 | # def has_key(self, key): 164 | 165 | def pop(self, key, *args, **kwargs): 166 | return super(CaseInsensitiveDict, self).pop( 167 | self.__class__._k(key), *args, **kwargs) 168 | 169 | def get(self, key, *args, **kwargs): 170 | return super(CaseInsensitiveDict, self).get( 171 | self.__class__._k(key), *args, **kwargs) 172 | 173 | def setdefault(self, key, *args, **kwargs): 174 | return super(CaseInsensitiveDict, self).setdefault( 175 | self.__class__._k(key), *args, **kwargs) 176 | 177 | def update(self, E={}, **F): 178 | super(CaseInsensitiveDict, self).update(self.__class__(E)) 179 | super(CaseInsensitiveDict, self).update(self.__class__(**F)) 180 | 181 | def _convert_keys(self): 182 | for k in list(self.keys()): 183 | v = super(CaseInsensitiveDict, self).pop(k) 184 | self.__setitem__(k, v) 185 | 186 | 187 | class HTTPResponse(BaseHTTPResponse): 188 | __slots__ = ("body", "status", "content_type", "headers", "_cookies") 189 | 190 | def __init__( 191 | self, 192 | body=None, 193 | status=200, 194 | headers=None, 195 | content_type="text/plain", 196 | body_bytes=b"", 197 | ): 198 | self.content_type = content_type 199 | 200 | if body is not None: 201 | self.body = self._encode_body(body) 202 | else: 203 | self.body = body_bytes 204 | 205 | self.status = status 206 | self.headers = CaseInsensitiveDict(headers or {}) 207 | self._cookies = None 208 | 209 | def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None): 210 | # This is all returned in a kind-of funky way 211 | # We tried to make this as fast as possible in pure python 212 | timeout_header = b"" 213 | if keep_alive and keep_alive_timeout is not None: 214 | timeout_header = b"Keep-Alive: %d\r\n" % keep_alive_timeout 215 | 216 | body = b"" 217 | if has_message_body(self.status): 218 | body = self.body 219 | self.headers["Content-Length"] = self.headers.get( 220 | "Content-Length", len(self.body) 221 | ) 222 | 223 | self.headers["Content-Type"] = self.headers.get( 224 | "Content-Type", self.content_type 225 | ) 226 | 227 | if self.status in (304, 412): 228 | self.headers = remove_entity_headers(self.headers) 229 | 230 | headers = self._parse_headers() 231 | 232 | if self.status == 200: 233 | status = b"OK" 234 | else: 235 | status = STATUS_CODES.get(self.status, b"UNKNOWN RESPONSE") 236 | 237 | return ( 238 | b"HTTP/%b %d %b\r\n" b"Connection: %b\r\n" b"%b" b"%b\r\n" b"%b" 239 | ) % ( 240 | version.encode(), 241 | self.status, 242 | status, 243 | b"keep-alive" if keep_alive else b"close", 244 | timeout_header, 245 | headers, 246 | body, 247 | ) 248 | 249 | 250 | def json( 251 | body, 252 | status=200, 253 | headers=None, 254 | content_type="application/json", 255 | dumps=json_dumps, 256 | **kwargs 257 | ): 258 | """ 259 | Returns response object with body in json format. 260 | 261 | :param body: Response data to be serialized. 262 | :param status: Response code. 263 | :param headers: Custom Headers. 264 | :param kwargs: Remaining arguments that are passed to the json encoder. 265 | """ 266 | return HTTPResponse( 267 | dumps(body, **kwargs), 268 | headers=headers, 269 | status=status, 270 | content_type=content_type, 271 | ) 272 | 273 | 274 | def text( 275 | body, status=200, headers=None, content_type="text/plain; charset=utf-8" 276 | ): 277 | """ 278 | Returns response object with body in text format. 279 | 280 | :param body: Response data to be encoded. 281 | :param status: Response code. 282 | :param headers: Custom Headers. 283 | :param content_type: the content type (string) of the response 284 | """ 285 | return HTTPResponse( 286 | body, status=status, headers=headers, content_type=content_type 287 | ) 288 | 289 | 290 | def raw( 291 | body, status=200, headers=None, content_type="application/octet-stream" 292 | ): 293 | """ 294 | Returns response object without encoding the body. 295 | 296 | :param body: Response data. 297 | :param status: Response code. 298 | :param headers: Custom Headers. 299 | :param content_type: the content type (string) of the response. 300 | """ 301 | return HTTPResponse( 302 | body_bytes=body, 303 | status=status, 304 | headers=headers, 305 | content_type=content_type, 306 | ) 307 | 308 | 309 | def html(body, status=200, headers=None): 310 | """ 311 | Returns response object with body in html format. 312 | 313 | :param body: Response data to be encoded. 314 | :param status: Response code. 315 | :param headers: Custom Headers. 316 | """ 317 | return HTTPResponse( 318 | body, 319 | status=status, 320 | headers=headers, 321 | content_type="text/html; charset=utf-8", 322 | ) 323 | 324 | 325 | def stream( 326 | streaming_fn, 327 | status=200, 328 | headers=None, 329 | content_type="text/plain; charset=utf-8", 330 | ): 331 | """Accepts an coroutine `streaming_fn` which can be used to 332 | write chunks to a streaming response. Returns a `StreamingHTTPResponse`. 333 | 334 | Example usage:: 335 | 336 | @app.route("/") 337 | async def index(request): 338 | async def streaming_fn(response): 339 | await response.write('foo') 340 | await response.write('bar') 341 | 342 | return stream(streaming_fn, content_type='text/plain') 343 | 344 | :param streaming_fn: A coroutine accepts a response and 345 | writes content to that response. 346 | :param mime_type: Specific mime_type. 347 | :param headers: Custom Headers. 348 | """ 349 | return StreamingHTTPResponse( 350 | streaming_fn, headers=headers, content_type=content_type, status=status 351 | ) 352 | 353 | 354 | def redirect( 355 | to, headers=None, status=302, content_type="text/html; charset=utf-8" 356 | ): 357 | """Abort execution and cause a 302 redirect (by default). 358 | 359 | :param to: path or fully qualified URL to redirect to 360 | :param headers: optional dict of headers to include in the new request 361 | :param status: status code (int) of the new request, defaults to 302 362 | :param content_type: the content type (string) of the response 363 | :returns: the redirecting Response 364 | """ 365 | headers = headers or {} 366 | 367 | # URL Quote the URL before redirecting 368 | safe_to = quote_plus(to, safe=":/%#?&=@[]!$&'()*+,;") 369 | 370 | # According to RFC 7231, a relative URI is now permitted. 371 | headers["Location"] = safe_to 372 | 373 | return HTTPResponse( 374 | status=status, headers=headers, content_type=content_type 375 | ) 376 | -------------------------------------------------------------------------------- /fdk/async_http/router.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from .exceptions import MethodNotSupported, NotFound 18 | 19 | 20 | from collections import namedtuple 21 | from functools import lru_cache 22 | 23 | ROUTER_CACHE_SIZE = 1024 24 | Route = namedtuple( 25 | "Route", ["handler", "methods", "path"] 26 | ) 27 | 28 | 29 | class Router(object): 30 | 31 | def __init__(self): 32 | self.__router_map = {} 33 | 34 | def add(self, path, methods, handler): 35 | self.__router_map[path] = Route( 36 | handler=handler, methods=methods, path=path) 37 | 38 | @lru_cache(maxsize=ROUTER_CACHE_SIZE) 39 | def get(self, request_path, request_method): 40 | try: 41 | rt = self.__router_map[request_path] 42 | if request_method not in rt.methods: 43 | raise MethodNotSupported( 44 | "Method {0} for path {1} not allowed" 45 | .format(request_method, request_path), 46 | request_method, rt.methods) 47 | return rt.handler, rt.path 48 | except Exception as ex: 49 | if isinstance(ex, KeyError): 50 | raise NotFound( 51 | "Route was not registered: {}" 52 | .format(request_path)) 53 | else: 54 | raise ex 55 | -------------------------------------------------------------------------------- /fdk/async_http/server.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import asyncio 18 | import logging 19 | import os 20 | 21 | from inspect import isawaitable 22 | from time import time 23 | 24 | from functools import partial 25 | from signal import SIG_IGN, SIGINT, SIGTERM 26 | from signal import signal as signal_func 27 | 28 | from .protocol import HttpProtocol 29 | 30 | from fdk import constants 31 | 32 | 33 | class Signal(object): 34 | stopped = False 35 | 36 | 37 | logger = logging.getLogger(__name__) 38 | 39 | 40 | def update_current_time(loop): 41 | """Cache the current time, since it is needed at the end of every 42 | keep-alive request to update the request timeout time 43 | 44 | :param loop: 45 | :return: 46 | """ 47 | global current_time 48 | current_time = time() 49 | loop.call_later(1, partial(update_current_time, loop)) 50 | 51 | 52 | def trigger_events(events, loop): 53 | """Trigger event callbacks (functions or async) 54 | 55 | :param events: one or more sync or async functions to execute 56 | :param loop: event loop 57 | """ 58 | for event in events: 59 | result = event(loop) 60 | if isawaitable(result): 61 | loop.run_until_complete(result) 62 | 63 | 64 | def serve( 65 | request_handler, 66 | error_handler, 67 | debug=False, 68 | request_timeout=60, 69 | response_timeout=60, 70 | keep_alive_timeout=75, 71 | ssl=None, 72 | sock=None, 73 | request_max_size=100000000, 74 | reuse_port=False, 75 | loop=None, 76 | protocol=HttpProtocol, 77 | backlog=100, 78 | register_sys_signals=True, 79 | run_multiple=False, 80 | run_async=False, 81 | connections=None, 82 | signal=Signal(), 83 | request_class=None, 84 | access_log=True, 85 | keep_alive=True, 86 | is_request_stream=False, 87 | router=None, 88 | websocket_max_size=None, 89 | websocket_max_queue=None, 90 | websocket_read_limit=2 ** 16, 91 | websocket_write_limit=2 ** 16, 92 | state=None, 93 | ): 94 | """Start asynchronous HTTP Server on an individual process. 95 | 96 | :param request_handler: Sanic request handler with middleware 97 | :param error_handler: Sanic error handler with middleware 98 | :param debug: enables debug output (slows server) 99 | :param request_timeout: time in seconds 100 | :param response_timeout: time in seconds 101 | :param keep_alive_timeout: time in seconds 102 | :param ssl: SSLContext 103 | :param sock: Socket for the server to accept connections from 104 | :param request_max_size: size in bytes, `None` for no limit 105 | :param reuse_port: `True` for multiple workers 106 | :param loop: asyncio compatible event loop 107 | :param protocol: subclass of asyncio protocol class 108 | :param request_class: Request class to use 109 | :param access_log: disable/enable access log 110 | :param websocket_max_size: enforces the maximum size for 111 | incoming messages in bytes. 112 | :param websocket_max_queue: sets the maximum length of the queue 113 | that holds incoming messages. 114 | :param websocket_read_limit: sets the high-water limit of the buffer for 115 | incoming bytes, the low-water limit is half 116 | the high-water limit. 117 | :param websocket_write_limit: sets the high-water limit of the buffer for 118 | outgoing bytes, the low-water limit is a 119 | quarter of the high-water limit. 120 | :param is_request_stream: disable/enable Request.stream 121 | :return: Nothing 122 | """ 123 | if not run_async: 124 | # create new event_loop after fork 125 | loop = asyncio.new_event_loop() 126 | asyncio.set_event_loop(loop) 127 | 128 | if debug: 129 | loop.set_debug(debug) 130 | 131 | connections = connections if connections is not None else set() 132 | server = partial( 133 | protocol, 134 | loop=loop, 135 | connections=connections, 136 | signal=signal, 137 | request_handler=request_handler, 138 | error_handler=error_handler, 139 | request_timeout=request_timeout, 140 | response_timeout=response_timeout, 141 | keep_alive_timeout=keep_alive_timeout, 142 | request_max_size=request_max_size, 143 | request_class=request_class, 144 | access_log=access_log, 145 | keep_alive=keep_alive, 146 | is_request_stream=is_request_stream, 147 | router=router, 148 | websocket_max_size=websocket_max_size, 149 | websocket_max_queue=websocket_max_queue, 150 | websocket_read_limit=websocket_read_limit, 151 | websocket_write_limit=websocket_write_limit, 152 | state=state, 153 | debug=debug, 154 | ) 155 | 156 | create_server_kwargs = dict( 157 | ssl=ssl, 158 | reuse_port=reuse_port, 159 | sock=sock, 160 | backlog=backlog, 161 | ) 162 | 163 | if constants.is_py37(): 164 | create_server_kwargs.update( 165 | start_serving=False 166 | ) 167 | 168 | server_coroutine = loop.create_server( 169 | server, **create_server_kwargs 170 | ) 171 | 172 | # Instead of pulling time at the end of every request, 173 | # pull it once per minute 174 | loop.call_soon(partial(update_current_time, loop)) 175 | 176 | if run_async: 177 | return server_coroutine 178 | 179 | try: 180 | http_server = loop.run_until_complete(server_coroutine) 181 | except BaseException: 182 | logger.exception("Unable to start server") 183 | return 184 | 185 | # Ignore SIGINT when run_multiple 186 | if run_multiple: 187 | signal_func(SIGINT, SIG_IGN) 188 | 189 | # Register signals for graceful termination 190 | if register_sys_signals: 191 | _signals = (SIGTERM,) if run_multiple else (SIGINT, SIGTERM) 192 | for _signal in _signals: 193 | try: 194 | loop.add_signal_handler(_signal, loop.stop) 195 | except NotImplementedError: 196 | logger.warning( 197 | "Sanic tried to use loop.add_signal_handler " 198 | "but it is not implemented on this platform." 199 | ) 200 | 201 | def start_serving(): 202 | if constants.is_py37(): 203 | loop.run_until_complete(http_server.start_serving()) 204 | 205 | def start(): 206 | pid = os.getpid() 207 | try: 208 | logger.debug("Starting worker [%s]", pid) 209 | if constants.is_py37(): 210 | loop.run_until_complete(http_server.serve_forever()) 211 | else: 212 | loop.run_forever() 213 | finally: 214 | http_server.close() 215 | loop.run_until_complete(http_server.wait_closed()) 216 | loop.run_until_complete(loop.shutdown_asyncgens()) 217 | loop.close() 218 | 219 | return start_serving, start 220 | -------------------------------------------------------------------------------- /fdk/async_http/signpost.md: -------------------------------------------------------------------------------- 1 | This code is from https://github.com/huge-success/sanic/ 2 | 3 | It's modified and stripped-down because importing full sanic 4 | increased cold starts. 5 | 6 | See https://github.com/fnproject/fdk-python/pull/60 for context. -------------------------------------------------------------------------------- /fdk/constants.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import sys 18 | from fdk import version 19 | 20 | ASYNC_IO_READ_BUFFER = 65536 21 | DEFAULT_DEADLINE = 30 22 | 23 | HTTPSTREAM = "http-stream" 24 | INTENT_HTTP_REQUEST = "httprequest" 25 | 26 | # env vars 27 | FN_FORMAT = "FN_FORMAT" 28 | FN_LISTENER = "FN_LISTENER" 29 | FN_APP_ID = "FN_APP_ID" 30 | FN_ID = "FN_FN_ID" 31 | FN_LOGFRAME_NAME = "FN_LOGFRAME_NAME" 32 | FN_LOGFRAME_HDR = "FN_LOGFRAME_HDR" 33 | FN_APP_NAME = "FN_APP_NAME" 34 | FN_NAME = "FN_FN_NAME" 35 | OCI_TRACE_COLLECTOR_URL = "OCI_TRACE_COLLECTOR_URL" 36 | OCI_TRACING_ENABLED = "OCI_TRACING_ENABLED" 37 | 38 | 39 | # headers are lower case TODO(denis): why? 40 | FN_INTENT = "fn-intent" 41 | FN_HTTP_PREFIX = "fn-http-h-" 42 | FN_HTTP_STATUS = "fn-http-status" 43 | FN_DEADLINE = "fn-deadline" 44 | FN_FDK_VERSION = "fn-fdk-version" 45 | FN_FDK_RUNTIME = "fn-fdk-runtime" 46 | FN_HTTP_REQUEST_URL = "fn-http-request-url" 47 | FN_CALL_ID = "fn-call-id" 48 | FN_HTTP_METHOD = "fn-http-method" 49 | CONTENT_TYPE = "content-type" 50 | CONTENT_LENGTH = "content-length" 51 | X_B3_TRACEID = "x-b3-traceid" 52 | X_B3_SPANID = "x-b3-spanid" 53 | X_B3_PARENTSPANID = "x-b3-parentspanid" 54 | X_B3_SAMPLED = "x-b3-sampled" 55 | X_B3_FLAGS = "x-b3-flags" 56 | 57 | FN_ENFORCED_RESPONSE_CODES = [200, 502, 504] 58 | FN_DEFAULT_RESPONSE_CODE = 200 59 | 60 | VERSION_HEADER_VALUE = "fdk-python/" + version.VERSION 61 | RUNTIME_HEADER_VALUE = "python/{}.{}.{} {}".format( 62 | sys.version_info.major, sys.version_info.minor, sys.version_info.micro, 63 | sys.version_info.releaselevel) 64 | 65 | 66 | # todo: python 3.8 is on its way, make more flexible 67 | def is_py37(): 68 | py_version = sys.version_info 69 | return (py_version.major, py_version.minor) == (3, 7) 70 | -------------------------------------------------------------------------------- /fdk/context.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import datetime as dt 18 | import io 19 | import os 20 | import random 21 | from fdk import constants 22 | from fdk import headers as hs 23 | from fdk import log 24 | from collections import namedtuple 25 | 26 | 27 | class InvokeContext(object): 28 | 29 | def __init__(self, app_id, app_name, fn_id, fn_name, call_id, 30 | content_type="application/octet-stream", 31 | deadline=None, config=None, 32 | headers=None, request_url=None, 33 | method="POST", fn_format=None, 34 | tracing_context=None): 35 | """ 36 | Request context here to be a placeholder 37 | for request-specific attributes 38 | :param app_id: Fn App ID 39 | :type app_id: str 40 | :param app_name: Fn App name 41 | :type app_name: str 42 | :param fn_id: Fn App Fn ID 43 | :type fn_id: str 44 | :param fn_name: Fn name 45 | :type fn_name: str 46 | :param call_id: Fn call ID 47 | :type call_id: str 48 | :param content_type: request content type 49 | :type content_type: str 50 | :param deadline: request deadline 51 | :type deadline: str 52 | :param config: an app/fn config 53 | :type config: dict 54 | :param headers: request headers 55 | :type headers: dict 56 | :param request_url: request URL 57 | :type request_url: str 58 | :param method: request method 59 | :type method: str 60 | :param fn_format: function format 61 | :type fn_format: str 62 | :param tracing_context: tracing context 63 | :type tracing_context: TracingContext 64 | """ 65 | self.__app_id = app_id 66 | self.__fn_id = fn_id 67 | self.__call_id = call_id 68 | self.__config = config if config else {} 69 | self.__headers = headers if headers else {} 70 | self.__http_headers = {} 71 | self.__deadline = deadline 72 | self.__content_type = content_type 73 | self._request_url = request_url 74 | self._method = method 75 | self.__response_headers = {} 76 | self.__fn_format = fn_format 77 | self.__app_name = app_name 78 | self.__fn_name = fn_name 79 | self.__tracing_context = tracing_context if tracing_context else None 80 | 81 | log.log("request headers. gateway: {0} {1}" 82 | .format(self.__is_gateway(), headers)) 83 | 84 | if self.__is_gateway(): 85 | self.__headers = hs.decap_headers(headers, True) 86 | self.__http_headers = hs.decap_headers(headers, False) 87 | 88 | def AppID(self): 89 | return self.__app_id 90 | 91 | def AppName(self): 92 | return self.__app_name 93 | 94 | def FnID(self): 95 | return self.__fn_id 96 | 97 | def FnName(self): 98 | return self.__fn_name 99 | 100 | def CallID(self): 101 | return self.__call_id 102 | 103 | def Config(self): 104 | return self.__config 105 | 106 | def Headers(self): 107 | return self.__headers 108 | 109 | def HTTPHeaders(self): 110 | return self.__http_headers 111 | 112 | def Format(self): 113 | return self.__fn_format 114 | 115 | def TracingContext(self): 116 | return self.__tracing_context 117 | 118 | def Deadline(self): 119 | if self.__deadline is None: 120 | now = dt.datetime.now(dt.timezone.utc).astimezone() 121 | now += dt.timedelta(0, float(constants.DEFAULT_DEADLINE)) 122 | return now.isoformat() 123 | return self.__deadline 124 | 125 | def SetResponseHeaders(self, headers, status_code): 126 | log.log("setting headers. gateway: {0}".format(self.__is_gateway())) 127 | if self.__is_gateway(): 128 | headers = hs.encap_headers(headers, status=status_code) 129 | 130 | for k, v in headers.items(): 131 | self.__response_headers[k.lower()] = v 132 | 133 | def GetResponseHeaders(self): 134 | return self.__response_headers 135 | 136 | def RequestURL(self): 137 | return self._request_url 138 | 139 | def Method(self): 140 | return self._method 141 | 142 | def __is_gateway(self): 143 | return (constants.FN_INTENT in self.__headers 144 | and self.__headers.get(constants.FN_INTENT) 145 | == constants.INTENT_HTTP_REQUEST) 146 | 147 | 148 | class TracingContext(object): 149 | 150 | def __init__(self, is_tracing_enabled, trace_collector_url, 151 | trace_id, span_id, parent_span_id, 152 | is_sampled, flags): 153 | """ 154 | Tracing context here to be a placeholder 155 | for tracing-specific attributes 156 | :param is_tracing_enabled: tracing enabled flag 157 | :type is_tracing_enabled: bool 158 | :param trace_collector_url: APM Trace Collector Endpoint URL 159 | :type trace_collector_url: str 160 | :param trace_id: Trace ID 161 | :type trace_id: str 162 | :param span_id: Span ID 163 | :type span_id: str 164 | :param parent_span_id: Parent Span ID 165 | :type parent_span_id: str 166 | :param is_sampled: Boolean for emmitting spans 167 | :type is_sampled: int (0 or 1) 168 | :param flags: Debug flags 169 | :type flags: int (0 or 1) 170 | """ 171 | self.__is_tracing_enabled = is_tracing_enabled 172 | self.__trace_collector_url = trace_collector_url 173 | self.__trace_id = trace_id 174 | self.__span_id = span_id 175 | self.__parent_span_id = parent_span_id 176 | self.__is_sampled = is_sampled 177 | self.__flags = flags 178 | self.__app_name = os.environ.get(constants.FN_APP_NAME) 179 | self.__app_id = os.environ.get(constants.FN_APP_ID) 180 | self.__fn_name = os.environ.get(constants.FN_NAME) 181 | self.__fn_id = os.environ.get(constants.FN_ID) 182 | 183 | self.__zipkin_attrs = self.__create_zipkin_attrs(is_tracing_enabled) 184 | 185 | def is_tracing_enabled(self): 186 | return self.__is_tracing_enabled 187 | 188 | def trace_collector_url(self): 189 | return self.__trace_collector_url 190 | 191 | def trace_id(self): 192 | return self.__trace_id 193 | 194 | def span_id(self): 195 | return self.__span_id 196 | 197 | def parent_span_id(self): 198 | return self.__parent_span_id 199 | 200 | def is_sampled(self): 201 | return bool(self.__is_sampled) 202 | 203 | def flags(self): 204 | return self.__flags 205 | 206 | def zipkin_attrs(self): 207 | return self.__zipkin_attrs 208 | 209 | # this is a helper method specific for py_zipkin 210 | def __create_zipkin_attrs(self, is_tracing_enabled): 211 | ZipkinAttrs = namedtuple( 212 | "ZipkinAttrs", 213 | "trace_id, span_id, parent_span_id, is_sampled, flags" 214 | ) 215 | 216 | trace_id = self.__trace_id 217 | span_id = self.__span_id 218 | parent_span_id = self.__parent_span_id 219 | is_sampled = bool(self.__is_sampled) 220 | trace_flags = self.__flags 221 | 222 | # As the fnLb sends the parent_span_id as the span_id 223 | # assign the parent span id as the span id. 224 | if is_tracing_enabled: 225 | parent_span_id = span_id 226 | span_id = generate_id() 227 | 228 | zipkin_attrs = ZipkinAttrs( 229 | trace_id, 230 | span_id, 231 | parent_span_id, 232 | is_sampled, 233 | trace_flags 234 | ) 235 | return zipkin_attrs 236 | 237 | def service_name(self, override=None): 238 | # in case of missing app and function name env variables 239 | service_name = ( 240 | override 241 | if override is not None 242 | else str(self.__app_name) + "::" + str(self.__fn_name) 243 | ) 244 | return service_name.lower() 245 | 246 | def annotations(self): 247 | annotations = { 248 | "generatedBy": "faas", 249 | "appName": self.__app_name, 250 | "appID": self.__app_id, 251 | "fnName": self.__fn_name, 252 | "fnID": self.__fn_id, 253 | } 254 | return annotations 255 | 256 | 257 | def generate_id(): 258 | return "{:016x}".format(random.getrandbits(64)) 259 | 260 | 261 | def context_from_format(format_def: str, **kwargs) -> ( 262 | InvokeContext, io.BytesIO): 263 | """ 264 | Creates a context from request 265 | :param format_def: function format 266 | :type format_def: str 267 | :param kwargs: request-specific map of parameters 268 | :return: invoke context and data 269 | :rtype: tuple 270 | """ 271 | 272 | app_id = os.environ.get(constants.FN_APP_ID) 273 | fn_id = os.environ.get(constants.FN_ID) 274 | app_name = os.environ.get(constants.FN_APP_NAME) 275 | fn_name = os.environ.get(constants.FN_NAME) 276 | # the tracing enabled env variable is passed as a "0" or "1" string 277 | # and therefore needs to be converted appropriately. 278 | is_tracing_enabled = os.environ.get(constants.OCI_TRACING_ENABLED) 279 | is_tracing_enabled = ( 280 | bool(int(is_tracing_enabled)) 281 | if is_tracing_enabled is not None 282 | else False 283 | ) 284 | trace_collector_url = os.environ.get(constants.OCI_TRACE_COLLECTOR_URL) 285 | 286 | if format_def == constants.HTTPSTREAM: 287 | data = kwargs.get("data") 288 | headers = kwargs.get("headers") 289 | 290 | # zipkin tracing http headers 291 | trace_id = span_id = parent_span_id = is_sampled = trace_flags = None 292 | tracing_context = None 293 | if is_tracing_enabled: 294 | # we generate the trace_id if tracing is enabled 295 | # but the traceId zipkin header is missing. 296 | trace_id = headers.get(constants.X_B3_TRACEID) 297 | trace_id = generate_id() if trace_id is None else trace_id 298 | 299 | span_id = headers.get(constants.X_B3_SPANID) 300 | parent_span_id = headers.get(constants.X_B3_PARENTSPANID) 301 | 302 | # span_id is also generated if the zipkin header is missing. 303 | span_id = generate_id() if span_id is None else span_id 304 | 305 | # is_sampled should be a boolean in the form of a "0/1" but 306 | # legacy samples have them as "False/True" 307 | is_sampled = headers.get(constants.X_B3_SAMPLED) 308 | is_sampled = int(is_sampled) if is_sampled is not None else 1 309 | 310 | # not currently used but is defined by the zipkin headers standard 311 | trace_flags = headers.get(constants.X_B3_FLAGS) 312 | 313 | # tracing context will be an empty object 314 | # if tracing is not enabled or the flag is missing. 315 | # this prevents the customer code from failing if they decide to 316 | # disable tracing. An empty tracing context will not 317 | # emit spans due to is_sampled being None. 318 | tracing_context = TracingContext( 319 | is_tracing_enabled, 320 | trace_collector_url, 321 | trace_id, 322 | span_id, 323 | parent_span_id, 324 | is_sampled, 325 | trace_flags 326 | ) 327 | 328 | method = headers.get(constants.FN_HTTP_METHOD) 329 | request_url = headers.get(constants.FN_HTTP_REQUEST_URL) 330 | deadline = headers.get(constants.FN_DEADLINE) 331 | call_id = headers.get(constants.FN_CALL_ID) 332 | content_type = headers.get(constants.CONTENT_TYPE) 333 | 334 | ctx = InvokeContext( 335 | app_id, app_name, fn_id, fn_name, call_id, 336 | content_type=content_type, 337 | deadline=deadline, 338 | config=os.environ, 339 | headers=headers, 340 | method=method, 341 | request_url=request_url, 342 | fn_format=constants.HTTPSTREAM, 343 | tracing_context=tracing_context, 344 | ) 345 | 346 | return ctx, data 347 | -------------------------------------------------------------------------------- /fdk/customer_code.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import os 18 | 19 | from fdk import constants 20 | 21 | 22 | def get_delayed_module_init_class(): 23 | if constants.is_py37(): 24 | return Python37DelayedImport 25 | else: 26 | return Python35plusDelayedImport 27 | 28 | 29 | class PythonDelayedImportAbstraction(object): 30 | 31 | def __init__(self, func_module_path): 32 | self._mod_path = func_module_path 33 | self._executed = False 34 | 35 | @property 36 | def executed(self): 37 | return self._executed 38 | 39 | @executed.setter 40 | def executed(self, exec_flag): 41 | self._executed = exec_flag 42 | 43 | def get_module(self): 44 | raise Exception("Not implemented") 45 | 46 | 47 | class Python35plusDelayedImport(PythonDelayedImportAbstraction): 48 | 49 | def __init__(self, func_module_path): 50 | self._func_module = None 51 | super(Python35plusDelayedImport, self).__init__(func_module_path) 52 | 53 | def get_module(self): 54 | if not self.executed: 55 | from importlib.machinery import SourceFileLoader 56 | fname, ext = os.path.splitext( 57 | os.path.basename(self._mod_path)) 58 | self._func_module = SourceFileLoader(fname, self._mod_path)\ 59 | .load_module() 60 | self.executed = True 61 | 62 | return self._func_module 63 | 64 | 65 | class Python37DelayedImport(PythonDelayedImportAbstraction): 66 | 67 | def import_from_source(self): 68 | from importlib import util 69 | func_module_spec = util.spec_from_file_location( 70 | "func", self._mod_path 71 | ) 72 | func_module = util.module_from_spec(func_module_spec) 73 | self._func_module_spec = func_module_spec 74 | self._func_module = func_module 75 | 76 | def get_module(self): 77 | if not self.executed: 78 | self.import_from_source() 79 | self._func_module_spec.loader.exec_module( 80 | self._func_module) 81 | self.executed = True 82 | 83 | return self._func_module 84 | 85 | 86 | class Function(object): 87 | 88 | def __init__(self, func_module_path, entrypoint="handler"): 89 | dm = get_delayed_module_init_class() 90 | self._delayed_module_class = dm(func_module_path) 91 | self._entrypoint = entrypoint 92 | 93 | def handler(self): 94 | mod = self._delayed_module_class.get_module() 95 | return getattr(mod, self._entrypoint) 96 | -------------------------------------------------------------------------------- /fdk/errors.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from fdk import constants 18 | from fdk import response 19 | 20 | 21 | class DispatchException(Exception): 22 | 23 | def __init__(self, ctx, status, message): 24 | """ 25 | JSON response with error 26 | :param status: HTTP status code 27 | :param message: error message 28 | """ 29 | self.status = status 30 | self.message = message 31 | self.ctx = ctx 32 | 33 | def response(self): 34 | resp_headers = { 35 | constants.CONTENT_TYPE: "application/json; charset=utf-8", 36 | } 37 | return response.Response( 38 | self.ctx, 39 | response_data=self.message, 40 | headers=resp_headers, 41 | status_code=self.status 42 | ) 43 | -------------------------------------------------------------------------------- /fdk/event_handler.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import io 18 | import logging 19 | import os 20 | import sys 21 | 22 | from fdk import constants 23 | from fdk import log 24 | 25 | from fdk.async_http import response 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | fn_logframe_name = os.environ.get(constants.FN_LOGFRAME_NAME) 30 | fn_logframe_hdr = os.environ.get(constants.FN_LOGFRAME_HDR) 31 | 32 | 33 | def event_handle(handle_code): 34 | """ 35 | Performs HTTP request-response procedure 36 | :param handle_code: customer's code 37 | :type handle_code: fdk.customer_code.Function 38 | :return: None 39 | """ 40 | async def pure_handler(request): 41 | from fdk import runner 42 | log.log("in pure_handler") 43 | headers = dict(request.headers) 44 | log_frame_header(headers) 45 | func_response = await runner.handle_request( 46 | handle_code, constants.HTTPSTREAM, 47 | headers=headers, data=io.BytesIO(request.body)) 48 | log.log("request execution completed") 49 | 50 | headers = func_response.context().GetResponseHeaders() 51 | status = func_response.status() 52 | if status not in constants.FN_ENFORCED_RESPONSE_CODES: 53 | status = constants.FN_DEFAULT_RESPONSE_CODE 54 | 55 | return response.HTTPResponse( 56 | headers=headers, 57 | status=status, 58 | content_type=headers.get(constants.CONTENT_TYPE), 59 | body_bytes=func_response.body_bytes(), 60 | ) 61 | 62 | return pure_handler 63 | 64 | 65 | def log_frame_header(headers): 66 | if all((fn_logframe_name, fn_logframe_hdr)): 67 | frm = fn_logframe_hdr.lower() 68 | if frm in headers: 69 | id = headers.get(frm) 70 | frm = "\n{}={}\n".format(fn_logframe_name, id) 71 | print(frm, file=sys.stderr, flush=True) 72 | print(frm, file=sys.stdout, flush=True) 73 | -------------------------------------------------------------------------------- /fdk/fixtures.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import datetime as dt 18 | 19 | from fdk import constants 20 | from fdk import runner 21 | from fdk import headers as hs 22 | 23 | 24 | async def process_response(fn_call_coro): 25 | resp = await fn_call_coro 26 | response_data = resp.body() 27 | response_status = resp.status() 28 | response_headers = resp.context().GetResponseHeaders() 29 | 30 | return response_data, response_status, response_headers 31 | 32 | 33 | class fake_request(object): 34 | 35 | def __init__(self, gateway=False): 36 | self.headers = setup_headers(gateway=gateway) 37 | self.body = b'' 38 | 39 | 40 | class code(object): 41 | 42 | def __init__(self, fn): 43 | self.fn = fn 44 | 45 | def handler(self): 46 | return self.fn 47 | 48 | 49 | def setup_headers(deadline=None, headers=None, 50 | request_url="/", method="POST", gateway=False): 51 | new_headers = {} 52 | 53 | if gateway: 54 | new_headers = hs.encap_headers(headers) 55 | new_headers.update({ 56 | constants.FN_INTENT: constants.INTENT_HTTP_REQUEST, 57 | }) 58 | elif headers is not None: 59 | for k, v in headers.items(): 60 | new_headers.update({k: v}) 61 | 62 | new_headers.update({ 63 | constants.FN_HTTP_REQUEST_URL: request_url, 64 | constants.FN_HTTP_METHOD: method, 65 | }) 66 | 67 | if deadline is None: 68 | now = dt.datetime.now(dt.timezone.utc).astimezone() 69 | now += dt.timedelta(0, float(constants.DEFAULT_DEADLINE)) 70 | deadline = now.isoformat() 71 | 72 | new_headers.update({constants.FN_DEADLINE: deadline}) 73 | return new_headers 74 | 75 | 76 | async def setup_fn_call( 77 | handle_func, request_url="/", 78 | method="POST", headers=None, 79 | content=None, deadline=None, 80 | gateway=False): 81 | 82 | new_headers = setup_headers( 83 | deadline=deadline, headers=headers, 84 | method=method, request_url=request_url, 85 | gateway=gateway 86 | ) 87 | return await setup_fn_call_raw(handle_func, content, new_headers) 88 | 89 | 90 | async def setup_fn_call_raw(handle_func, content=None, headers=None): 91 | 92 | if headers is None: 93 | headers = {} 94 | 95 | # don't decap headers, so we can test them 96 | # (just like they come out of fdk) 97 | return process_response(runner.handle_request( 98 | code(handle_func), constants.HTTPSTREAM, 99 | headers=headers, data=content, 100 | )) 101 | -------------------------------------------------------------------------------- /fdk/headers.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from fdk import constants 18 | 19 | 20 | def decap_headers(hdsr, merge=True): 21 | ctx_headers = {} 22 | if hdsr is not None: 23 | for k, v in hdsr.items(): 24 | k = k.lower() 25 | if k.startswith(constants.FN_HTTP_PREFIX): 26 | push_header(ctx_headers, k[len(constants.FN_HTTP_PREFIX):], v) 27 | elif merge: 28 | # http headers override functions headers in context 29 | # this is not ideal but it's the more correct view from the 30 | # consumer perspective than random choice and for things 31 | # like host headers 32 | if k not in ctx_headers: 33 | ctx_headers[k] = v 34 | return ctx_headers 35 | 36 | 37 | def push_header(input_map, key, value): 38 | if key not in input_map: 39 | input_map[key] = value 40 | return 41 | 42 | current_val = input_map[key] 43 | 44 | if isinstance(current_val, list): 45 | if isinstance(value, list): # both lists concat 46 | input_map[key] = current_val + value 47 | else: # copy and append current value 48 | new_val = current_val.copy() 49 | new_val.append(value) 50 | input_map[key] = new_val 51 | else: 52 | if isinstance(value, list): # copy new list value and prepend current 53 | new_value = value.copy() 54 | new_value.insert(0, current_val) 55 | input_map[key] = new_value 56 | else: # both non-lists create a new list 57 | input_map[key] = [current_val, value] 58 | 59 | 60 | def encap_headers(headers, status=None): 61 | new_headers = {} 62 | if headers is not None: 63 | for k, v in headers.items(): 64 | k = k.lower() 65 | if k.startswith(constants.FN_HTTP_PREFIX): # by default merge 66 | push_header(new_headers, k, v) 67 | if (k == constants.CONTENT_TYPE 68 | or k == constants.FN_FDK_VERSION 69 | or k == constants.FN_FDK_RUNTIME): # but don't merge these 70 | new_headers[k] = v 71 | else: 72 | push_header(new_headers, constants.FN_HTTP_PREFIX + k, v) 73 | 74 | if status is not None: 75 | new_headers[constants.FN_HTTP_STATUS] = str(status) 76 | 77 | return new_headers 78 | -------------------------------------------------------------------------------- /fdk/log.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import logging 18 | import os 19 | import sys 20 | from contextvars import ContextVar 21 | 22 | 23 | __fn_request_id__ = ContextVar("fn_request_id", default=None) 24 | 25 | 26 | def set_request_id(rid): 27 | __fn_request_id__.set(rid) 28 | 29 | 30 | class RequestFormatter(logging.Formatter): 31 | def format(self, record): 32 | record.fn_request_id = __fn_request_id__.get() 33 | return super().format(record) 34 | 35 | 36 | def __setup_logger(): 37 | fdk_debug = os.environ.get("FDK_DEBUG") in [ 38 | 'true', '1', 't', 'y', 'yes', 'yeah', 'yup', 'certainly', 'uh-huh'] 39 | 40 | logging.getLogger("asyncio").setLevel(logging.WARNING) 41 | root = logging.getLogger() 42 | root.setLevel(logging.DEBUG) 43 | 44 | ch = logging.StreamHandler(sys.stderr) 45 | formatter = RequestFormatter( 46 | '%(fn_request_id)s - ' 47 | '%(name)s - ' 48 | '%(levelname)s - ' 49 | '%(message)s' 50 | ) 51 | ch.setFormatter(formatter) 52 | root.addHandler(ch) 53 | logger = logging.getLogger("fdk") 54 | if fdk_debug: 55 | logger.setLevel(logging.DEBUG) 56 | else: 57 | logger.setLevel(logging.WARNING) 58 | 59 | return logger 60 | 61 | 62 | __log__ = __setup_logger() 63 | 64 | 65 | def get_logger(): 66 | return __log__ 67 | 68 | 69 | def log(message): 70 | __log__.debug(message) 71 | 72 | 73 | __request_log__ = logging.getLogger('fn') 74 | 75 | 76 | def get_request_log(): 77 | return __request_log__ 78 | -------------------------------------------------------------------------------- /fdk/response.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from fdk import context 18 | from fdk import constants 19 | from typing import Union 20 | 21 | 22 | class Response(object): 23 | 24 | def __init__(self, ctx: context.InvokeContext, 25 | response_data: Union[str, bytes] = None, 26 | headers: dict = None, 27 | status_code: int = 200, 28 | response_encoding: str = "utf-8"): 29 | """ 30 | Creates an FDK-readable response object 31 | :param ctx: invoke context 32 | :type ctx: fdk.context.InvokeContext 33 | :param response_data: function's response data 34 | :type response_data: str 35 | :param headers: response headers 36 | :type headers: dict 37 | :param status_code: response code 38 | :type status_code: int 39 | :param response_encoding: response encoding for strings ("utf-8") 40 | :type response_encoding: str 41 | """ 42 | self.ctx = ctx 43 | self.status_code = status_code 44 | self.response_data = response_data if response_data else "" 45 | self.response_encoding = response_encoding 46 | 47 | if headers is None: 48 | headers = {} 49 | headers.update({constants.FN_FDK_VERSION: 50 | constants.VERSION_HEADER_VALUE, 51 | constants.FN_FDK_RUNTIME: 52 | constants.RUNTIME_HEADER_VALUE, }) 53 | ctx.SetResponseHeaders(headers, status_code) 54 | self.ctx = ctx 55 | 56 | def status(self): 57 | return self.status_code 58 | 59 | def body(self): 60 | return self.response_data 61 | 62 | def body_bytes(self): 63 | if isinstance(self.response_data, bytes) or \ 64 | isinstance(self.response_data, bytearray): 65 | return self.response_data 66 | else: 67 | return str(self.response_data).encode(self.response_encoding) 68 | 69 | def context(self): 70 | return self.ctx 71 | -------------------------------------------------------------------------------- /fdk/runner.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import io 18 | import sys 19 | import traceback 20 | import types 21 | 22 | from fdk import context 23 | from fdk import customer_code 24 | from fdk import errors 25 | from fdk import log 26 | from fdk import response 27 | 28 | 29 | async def with_deadline(ctx: context.InvokeContext, 30 | handler_code: customer_code.Function, 31 | data: io.BytesIO): 32 | """ 33 | Runs function within a timer 34 | :param ctx: invoke context 35 | :type ctx: fdk.context.InvokeContext 36 | :param handler_code: customer's code 37 | :type handler_code: fdk.customer_code.Function 38 | :param data: request data stream 39 | :type data: io.BytesIO 40 | :return: 41 | """ 42 | 43 | # ctx.Deadline() would never be an empty value, 44 | # by default it will be 30 secs from now 45 | 46 | try: 47 | handle_func = handler_code.handler() 48 | result = handle_func(ctx, data=data) 49 | if isinstance(result, types.CoroutineType): 50 | return await result 51 | 52 | return result 53 | except (Exception, TimeoutError) as ex: 54 | raise ex 55 | 56 | 57 | async def handle_request(handler_code, format_def, **kwargs): 58 | """ 59 | Handles a function's request 60 | :param handler_code: customer's code 61 | :type handler_code: fdk.customer_code.Function 62 | :param format_def: function's format 63 | :type format_def: str 64 | :param kwargs: request-specific parameters 65 | :type kwargs: dict 66 | :return: function's response 67 | :rtype: fdk.response.Response 68 | """ 69 | log.log("in handle_request") 70 | ctx, body = context.context_from_format(format_def, **kwargs) 71 | log.set_request_id(ctx.CallID()) 72 | log.log("context provisioned") 73 | try: 74 | response_data = await with_deadline(ctx, handler_code, body) 75 | log.log("function result obtained") 76 | if isinstance(response_data, response.Response): 77 | return response_data 78 | headers = ctx.GetResponseHeaders() 79 | log.log("response headers obtained") 80 | return response.Response( 81 | ctx, response_data=response_data, 82 | headers=headers, status_code=200) 83 | 84 | except TimeoutError as ex: 85 | log.log("Function timeout: {}".format(ex)) 86 | (exctype, value, tb) = sys.exc_info() 87 | tb_flat = ''.join( 88 | s.replace('\n', '\\n') for s in traceback.format_tb(tb)) 89 | log.get_request_log().error('{}:{}'.format(value, tb_flat)) 90 | return errors.DispatchException(ctx, 504, str(ex)).response() 91 | except Exception as ex: 92 | log.log("exception appeared: {0}".format(ex)) 93 | (exctype, value, tb) = sys.exc_info() 94 | tb_flat = ''.join( 95 | s.replace('\n', '\\n') for s in traceback.format_tb(tb)) 96 | log.get_request_log().error('{}:{}'.format(value, tb_flat)) 97 | return errors.DispatchException(ctx, 502, str(ex)).response() 98 | -------------------------------------------------------------------------------- /fdk/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fnproject/fdk-python/01c04ed039a437584958816f35956b3dbd53d841/fdk/scripts/__init__.py -------------------------------------------------------------------------------- /fdk/scripts/fdk.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import fdk 18 | import os 19 | import sys 20 | 21 | from fdk import customer_code 22 | 23 | 24 | def main(): 25 | if len(sys.argv) < 1: 26 | print("Usage: fdk [entrypoint]") 27 | sys.exit("at least func module must be specified") 28 | 29 | if not os.path.exists(sys.argv[1]): 30 | sys.exit("Module: {0} doesn't exist".format(sys.argv[1])) 31 | 32 | if len(sys.argv) > 2: 33 | handler = customer_code.Function( 34 | sys.argv[1], entrypoint=sys.argv[2]) 35 | else: 36 | handler = customer_code.Function(sys.argv[1]) 37 | 38 | fdk.handle(handler) 39 | -------------------------------------------------------------------------------- /fdk/scripts/fdk_tcp_debug.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import os 18 | import sys 19 | 20 | from fdk import customer_code 21 | 22 | from fdk.tests import tcp_debug 23 | 24 | 25 | def main(): 26 | if len(sys.argv) < 3: 27 | print("Usage: fdk-tcp-debug [entrypoint]") 28 | sys.exit("at least func module must be specified") 29 | 30 | if not os.path.exists(sys.argv[2]): 31 | sys.exit("Module: {0} doesn't exist".format(sys.argv[1])) 32 | 33 | if len(sys.argv) > 3: 34 | handler = customer_code.Function( 35 | sys.argv[2], entrypoint=sys.argv[3]) 36 | else: 37 | handler = customer_code.Function(sys.argv[1]) 38 | 39 | tcp_debug.handle(handler, port=int(sys.argv[1])) 40 | -------------------------------------------------------------------------------- /fdk/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fnproject/fdk-python/01c04ed039a437584958816f35956b3dbd53d841/fdk/tests/__init__.py -------------------------------------------------------------------------------- /fdk/tests/funcs.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import time 18 | import json 19 | 20 | from fdk import response 21 | 22 | xml = """ 23 | 24 | 1979-09-23 25 | Orene Ai'i 26 | Blues 27 | 453 28 | 170 29 | FW 30 | 75 31 | """ 32 | 33 | 34 | def code404(ctx, **kwargs): 35 | return response.Response(ctx, status_code=404) 36 | 37 | 38 | def code502(ctx, **kwargs): 39 | return response.Response(ctx, status_code=502) 40 | 41 | 42 | def code504(ctx, **kwargs): 43 | return response.Response(ctx, status_code=504) 44 | 45 | 46 | def dummy_func(ctx, data=None): 47 | if data is not None and len(data) > 0: 48 | body = json.loads(data) 49 | else: 50 | body = {"name": "World"} 51 | return "Hello {0}".format(body.get("name")) 52 | 53 | 54 | def encaped_header(ctx, **kwargs): 55 | return response.Response( 56 | ctx, response_data="OK", status_code=200, 57 | headers=ctx.Headers()) 58 | 59 | 60 | def content_type(ctx, data=None): 61 | return response.Response( 62 | ctx, response_data="OK", status_code=200, 63 | headers={"Content-Type": "application/json"}) 64 | 65 | 66 | def custom_response(ctx, data=None): 67 | return response.Response( 68 | ctx, 69 | response_data=dummy_func(ctx, data=data), 70 | status_code=201) 71 | 72 | 73 | def expectioner(ctx, data=None): 74 | raise Exception("custom_error") 75 | 76 | 77 | def none_func(ctx, data=None): 78 | return 79 | 80 | 81 | def timed_sleepr(timeout): 82 | def sleeper(ctx, data=None): 83 | time.sleep(timeout) 84 | 85 | return sleeper 86 | 87 | 88 | async def coro(ctx, **kwargs): 89 | return "hello from coro" 90 | 91 | 92 | def valid_xml(ctx, **kwargs): 93 | return response.Response( 94 | ctx, response_data=xml, headers={ 95 | "content-type": "application/xml", 96 | } 97 | ) 98 | 99 | 100 | def invalid_xml(ctx, **kwargs): 101 | return response.Response( 102 | ctx, response_data=json.dumps(xml), headers={ 103 | "content-type": "application/xml", 104 | } 105 | ) 106 | 107 | 108 | def verify_request_headers(ctx, **kwargs): 109 | return response.Response( 110 | ctx, 111 | response_data=json.dumps(xml), 112 | headers=ctx.Headers() 113 | ) 114 | 115 | 116 | def access_request_url(ctx, **kwargs): 117 | method = ctx.Method() 118 | request_url = ctx.RequestURL() 119 | return response.Response( 120 | ctx, response_data="OK", headers={ 121 | "Response-Request-URL": request_url, 122 | "Request-Method": method, 123 | } 124 | ) 125 | 126 | 127 | captured_context = None 128 | 129 | 130 | def setup_context_capture(): 131 | global captured_context 132 | captured_context = None 133 | 134 | 135 | def get_captured_context(): 136 | global captured_context 137 | my_context = captured_context 138 | captured_context = None 139 | return my_context 140 | 141 | 142 | def capture_request_ctx(ctx, **kwargs): 143 | global captured_context 144 | captured_context = ctx 145 | return response.Response(ctx, response_data="OK") 146 | 147 | 148 | def binary_result(ctx, **kwargs): 149 | return response.Response(ctx, response_data=bytes([1, 2, 3, 4, 5])) 150 | -------------------------------------------------------------------------------- /fdk/tests/tcp_debug.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import asyncio 18 | import socket 19 | 20 | from fdk import customer_code 21 | from fdk import event_handler 22 | from fdk import log 23 | 24 | from fdk.async_http import app 25 | from fdk.async_http import router 26 | 27 | 28 | def handle(handle_code: customer_code.Function, port: int = 5000): 29 | """ 30 | FDK entry point 31 | :param handle_code: customer's code 32 | :type handle_code: fdk.customer_code.Function 33 | :param port: TCP port to start an FDK at 34 | :type port: int 35 | :return: None 36 | """ 37 | host = "localhost" 38 | log.log("entering handle") 39 | 40 | log.log("Starting HTTP server on " 41 | "TCP socket: {0}:{1}".format(host, port)) 42 | loop = asyncio.get_event_loop() 43 | 44 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 45 | sock.bind(("localhost", port)) 46 | 47 | rtr = router.Router() 48 | rtr.add("/call", frozenset({"POST"}), 49 | event_handler.event_handle(handle_code)) 50 | srv = app.AsyncHTTPServer(name="fdk-tcp-debug", router=rtr) 51 | start_serving, server_forever = srv.run(sock=sock, loop=loop) 52 | start_serving() 53 | server_forever() 54 | -------------------------------------------------------------------------------- /fdk/tests/test_delayed_loader.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import pytest 18 | 19 | from fdk import constants 20 | from fdk import customer_code 21 | 22 | from fdk.tests import funcs 23 | 24 | 25 | @pytest.mark.skipif(constants.is_py37(), 26 | reason="this test is for Python 3.5.2+ only") 27 | def test_py352plus(): 28 | dm = customer_code.Python35plusDelayedImport(funcs.__file__) 29 | assert dm.executed is False 30 | 31 | m = dm.get_module() 32 | assert dm.executed is True 33 | assert m is not None 34 | 35 | 36 | @pytest.mark.skipif(not constants.is_py37(), 37 | reason="this test is for Python 3.7.1+ only") 38 | def test_py37(): 39 | dm = customer_code.Python37DelayedImport(funcs.__file__) 40 | assert dm.executed is False 41 | 42 | m = dm.get_module() 43 | assert dm.executed is True 44 | assert m is not None 45 | 46 | 47 | def test_generic_delayed_loader(): 48 | f = customer_code.Function( 49 | funcs.__file__, entrypoint="content_type") 50 | assert f is not None 51 | assert f._delayed_module_class.executed is False 52 | 53 | h = f.handler() 54 | assert h is not None 55 | assert f._delayed_module_class.executed is True 56 | -------------------------------------------------------------------------------- /fdk/tests/test_headers.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from fdk import headers 18 | 19 | 20 | def test_push_header(): 21 | cases = [ 22 | ({}, "k", "v", {"k": "v"}), 23 | ({}, "k", ["v1", "v2"], {"k": ["v1", "v2"]}), 24 | ({"k": "v1"}, "k", "v2", {"k": ["v1", "v2"]}), 25 | ({"k": ["v1"]}, "k", "v2", {"k": ["v1", "v2"]}), 26 | ({"k": ["v1"]}, "k", ["v2"], {"k": ["v1", "v2"]}), 27 | ({"k": []}, "k", [], {"k": []}), 28 | ({"k": ["v1"]}, "k", [], {"k": ["v1"]}), 29 | ({"k": []}, "k", ["v1"], {"k": ["v1"]}), 30 | ({"k": "v1"}, "k", ["v2", "v3"], {"k": ["v1", "v2", "v3"]}), 31 | ({"k1": "v1"}, "k2", "v2", {"k1": "v1", "k2": "v2"}), 32 | 33 | ] 34 | 35 | for case in cases: 36 | initial = case[0] 37 | working = initial.copy() 38 | key = case[1] 39 | value = case[2] 40 | result = case[3] 41 | headers.push_header(working, key, value) 42 | assert working == result, "Adding %s:%s to %s" \ 43 | % (key, value, initial) 44 | 45 | 46 | def test_encap_no_headers(): 47 | encap = headers.encap_headers({}) 48 | assert not encap, "headers should be empty" 49 | 50 | 51 | def test_encap_simple_headers(): 52 | encap = headers.encap_headers({ 53 | "Test-header": "foo", 54 | "name-Conflict": "h1", 55 | "name-conflict": "h2", 56 | "nAme-conflict": ["h3", "h4"], 57 | "fn-http-h-name-conflict": "h5", 58 | "multi-header": ["bar", "baz"] 59 | }) 60 | assert "fn-http-h-test-header" in encap 61 | assert "fn-http-h-name-conflict" in encap 62 | assert "fn-http-h-multi-header" in encap 63 | 64 | assert encap["fn-http-h-test-header"] == "foo" 65 | assert set(encap["fn-http-h-name-conflict"]) == {"h1", "h2", 66 | "h3", "h4", "h5"} 67 | assert encap["fn-http-h-multi-header"] == ["bar", "baz"] 68 | 69 | 70 | def test_encap_status(): 71 | encap = headers.encap_headers({}, 202) 72 | assert "fn-http-status" in encap 73 | assert encap["fn-http-status"] == "202" 74 | 75 | 76 | def test_encap_status_override(): 77 | encap = headers.encap_headers({"fn-http-status": 412}, 202) 78 | assert "fn-http-status" in encap 79 | assert encap["fn-http-status"] == "202" 80 | 81 | 82 | def test_content_type_version(): 83 | encap = headers.encap_headers({"content-type": "text/plain", 84 | "fn-fdk-version": "1.2.3"}) 85 | 86 | assert encap == {"content-type": "text/plain", "fn-fdk-version": "1.2.3"} 87 | 88 | 89 | def test_content_runtime_version(): 90 | encap = headers.encap_headers({"content-type": "text/plain", 91 | "fn-fdk-runtime": "python:3.8.8 final"}) 92 | 93 | assert encap == {"content-type": "text/plain", 94 | "fn-fdk-runtime": "python:3.8.8 final", } 95 | 96 | 97 | def test_decap_headers_merge(): 98 | decap = headers.decap_headers({"fn-http-h-Foo-Header": "v1", 99 | "fn-http-h-merge-header": "v2", 100 | "fn-http-h-merge-Header": ["v3"], 101 | "Foo-Header": "ignored", 102 | "other-header": "bob"}, True) 103 | assert "foo-header" in decap 104 | assert decap["foo-header"] == "v1" 105 | 106 | assert "other-header" in decap 107 | assert decap["other-header"] == "bob" 108 | 109 | assert "merge-header" in decap 110 | assert set(decap["merge-header"]) == {"v2", "v3"} 111 | 112 | 113 | def test_decap_headers_strip(): 114 | decap = headers.decap_headers({"fn-http-h-Foo-Header": "v1", 115 | "fn-http-h-merge-header": ["v2"], 116 | "Foo-Header": "ignored", 117 | "merge-header": "v3", 118 | "other-header": "bad"}, False) 119 | assert decap == {"foo-header": "v1", "merge-header": ["v2"]} 120 | -------------------------------------------------------------------------------- /fdk/tests/test_http_stream.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import json 18 | import pytest 19 | 20 | from fdk import constants 21 | from fdk import event_handler 22 | from fdk import fixtures 23 | 24 | from fdk.tests import funcs 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_override_content_type(): 29 | call = await fixtures.setup_fn_call( 30 | funcs.content_type) 31 | content, status, headers = await call 32 | 33 | assert 200 == status 34 | assert "OK" == content 35 | assert headers.get("content-type") == "application/json" 36 | # we've had issues with 'Content-Type: None' slipping in 37 | assert headers.get("Content-Type") is None 38 | assert headers.get( 39 | constants.FN_FDK_VERSION) == constants.VERSION_HEADER_VALUE 40 | 41 | 42 | @pytest.mark.asyncio 43 | async def test_parse_request_without_data(): 44 | call = await fixtures.setup_fn_call(funcs.dummy_func) 45 | 46 | content, status, headers = await call 47 | assert 200 == status 48 | assert "Hello World" == content 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_parse_request_with_data(): 53 | input_content = json.dumps( 54 | {"name": "John"}).encode("utf-8") 55 | call = await fixtures.setup_fn_call( 56 | funcs.dummy_func, content=input_content) 57 | content, status, headers = await call 58 | 59 | assert 200 == status 60 | assert "Hello John" == content 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_custom_response_object(): 65 | input_content = json.dumps( 66 | {"name": "John"}).encode("utf-8") 67 | call = await fixtures.setup_fn_call( 68 | funcs.custom_response, input_content) 69 | content, status, headers = await call 70 | 71 | assert 201 == status 72 | 73 | 74 | @pytest.mark.asyncio 75 | async def test_encap_headers_gw(): 76 | call = await fixtures.setup_fn_call( 77 | funcs.encaped_header, 78 | headers={ 79 | "custom-header-maybe": "yo", 80 | "content-type": "application/yo" 81 | }, 82 | gateway=True, 83 | ) 84 | content, status, headers = await call 85 | 86 | # make sure that content type is not encaped, and custom header is 87 | # when coming out of the fdk 88 | assert 200 == status 89 | assert "application/yo" in headers.get("content-type") 90 | assert "yo" in headers.get("fn-http-h-custom-header-maybe") 91 | 92 | 93 | @pytest.mark.asyncio 94 | async def test_encap_headers(): 95 | call = await fixtures.setup_fn_call( 96 | funcs.encaped_header, 97 | headers={ 98 | "custom-header-maybe": "yo", 99 | "content-type": "application/yo" 100 | } 101 | ) 102 | content, status, headers = await call 103 | 104 | # make sure that custom header is not encaped out of fdk 105 | assert 200 == status 106 | assert "application/yo" in headers.get("content-type") 107 | assert "yo" in headers.get("custom-header-maybe") 108 | 109 | 110 | @pytest.mark.asyncio 111 | async def test_errored_func(): 112 | call = await fixtures.setup_fn_call(funcs.expectioner) 113 | content, status, headers = await call 114 | 115 | assert 502 == status 116 | 117 | 118 | @pytest.mark.asyncio 119 | async def test_none_func(): 120 | call = await fixtures.setup_fn_call(funcs.none_func) 121 | content, status, headers = await call 122 | 123 | assert 0 == len(content) 124 | assert 200 == status 125 | 126 | 127 | @pytest.mark.asyncio 128 | async def test_coro_func(): 129 | call = await fixtures.setup_fn_call(funcs.coro) 130 | content, status, headers = await call 131 | 132 | assert 200 == status 133 | assert 'hello from coro' == content 134 | 135 | 136 | @pytest.mark.asyncio 137 | async def test_default_enforced_response_code(): 138 | event_coro = event_handler.event_handle( 139 | fixtures.code(funcs.code404)) 140 | 141 | http_resp = await event_coro(fixtures.fake_request(gateway=True)) 142 | 143 | assert http_resp.status == 200 144 | assert http_resp.headers.get(constants.FN_HTTP_STATUS) == "404" 145 | 146 | 147 | @pytest.mark.asyncio 148 | async def test_enforced_response_codes_502(): 149 | event_coro = event_handler.event_handle( 150 | fixtures.code(funcs.code502)) 151 | 152 | http_resp = await event_coro(fixtures.fake_request(gateway=True)) 153 | 154 | assert http_resp.status == 502 155 | assert http_resp.headers.get(constants.FN_HTTP_STATUS) == "502" 156 | 157 | 158 | @pytest.mark.asyncio 159 | async def test_enforced_response_codes_504(): 160 | event_coro = event_handler.event_handle( 161 | fixtures.code(funcs.code504)) 162 | 163 | http_resp = await event_coro(fixtures.fake_request(gateway=True)) 164 | 165 | assert http_resp.status == 504 166 | assert http_resp.headers.get(constants.FN_HTTP_STATUS) == "504" 167 | 168 | 169 | def test_log_frame_header(monkeypatch, capsys): 170 | monkeypatch.setattr("fdk.event_handler.fn_logframe_name", "foo") 171 | monkeypatch.setattr("fdk.event_handler.fn_logframe_hdr", "Fn-Call-Id") 172 | headers = {"fn-call-id": 12345} 173 | 174 | event_handler.log_frame_header(headers) 175 | 176 | captured = capsys.readouterr() 177 | assert "\nfoo=12345\n" in captured.out 178 | assert "\nfoo=12345\n" in captured.err 179 | 180 | 181 | @pytest.mark.asyncio 182 | async def test_request_url_and_method_set_with_gateway(): 183 | headers = { 184 | "fn-http-method": "PUT", 185 | "fn-http-request-url": "/foo-bar?baz", 186 | "fn-http-h-not-aheader": "nothttp" 187 | } 188 | 189 | funcs.setup_context_capture() 190 | 191 | call = await fixtures.setup_fn_call_raw( 192 | funcs.capture_request_ctx, 193 | headers=headers 194 | ) 195 | content, status, headers = await call 196 | assert content == "OK" 197 | 198 | ctx = funcs.get_captured_context() 199 | 200 | assert ctx.RequestURL() == "/foo-bar?baz", "request URL mismatch, got %s" \ 201 | % ctx.RequestURL() 202 | assert ctx.Method() == "PUT", "method mismatch got %s" % ctx.Method() 203 | assert "fn-http-h-not-aheader" in ctx.Headers() 204 | assert ctx.Headers()["fn-http-h-not-aheader"] == "nothttp" 205 | 206 | 207 | @pytest.mark.asyncio 208 | async def test_encap_request_headers_gateway(): 209 | headers = { 210 | "fn-intent": "httprequest", 211 | "fn-http-h-my-header": "foo", 212 | "fn-http-h-funny-header": ["baz", "bob"], 213 | "funny-header": "not-this-one", 214 | } 215 | 216 | funcs.setup_context_capture() 217 | call = await fixtures.setup_fn_call_raw( 218 | funcs.capture_request_ctx, 219 | content=None, 220 | headers=headers 221 | ) 222 | 223 | content, status, headers = await call 224 | 225 | assert content == 'OK' 226 | 227 | input_ctx = funcs.get_captured_context() 228 | 229 | headers = input_ctx.Headers() 230 | 231 | assert "my-header" in headers 232 | assert "funny-header" in headers 233 | 234 | assert headers["my-header"] == "foo" 235 | assert headers["funny-header"] == ["baz", "bob"] 236 | 237 | assert input_ctx.HTTPHeaders() == {"my-header": "foo", 238 | "funny-header": ["baz", "bob"]} 239 | 240 | 241 | @pytest.mark.asyncio 242 | async def test_bytes_response(): 243 | call = await fixtures.setup_fn_call(funcs.binary_result) 244 | content, status, headers = await call 245 | assert content == bytes([1, 2, 3, 4, 5]) 246 | -------------------------------------------------------------------------------- /fdk/tests/test_protocol.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | from fdk.async_http import protocol 18 | 19 | 20 | def http_protocol(**kwargs): 21 | return protocol.HttpProtocol( 22 | loop=None, request_handler=None, error_handler=None, 23 | **kwargs) 24 | 25 | 26 | def test_keep_alive_time_left_before_time_is_set(): 27 | p = http_protocol() 28 | 29 | protocol.current_time = None 30 | p._last_request_time = None 31 | 32 | time_left = p.keep_alive_time_left() 33 | assert 0 == time_left 34 | 35 | 36 | def test_keep_alive_time_left_after_time_is_set(): 37 | p = http_protocol(keep_alive_timeout=15) 38 | 39 | protocol.current_time = 96 40 | p._last_response_time = 64 41 | 42 | time_left = p.keep_alive_time_left() 43 | assert 15 - 32 == time_left 44 | -------------------------------------------------------------------------------- /fdk/tests/test_tracing.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import os 18 | 19 | from fdk import constants 20 | from fdk.context import TracingContext 21 | from fdk.context import InvokeContext 22 | from fdk.context import context_from_format 23 | 24 | app_id = "some_app_id" 25 | app_name = "mock_app" 26 | fn_id = "some_fn_id" 27 | fn_name = "mock_fn" 28 | 29 | is_tracing_enabled = True 30 | trace_collector_url = "some_url_endpoint" 31 | trace_id = "some_trace_id" 32 | span_id = "some_span_id" 33 | parent_span_id = "some_parent_span_id" 34 | is_sampled = 1 35 | trace_flags = 0 36 | 37 | headers = { 38 | 'host': 'localhost', 39 | 'user-agent': 'curl/7.64.1', 40 | 'content-length': '0', 41 | 'accept': '*/*', 42 | 'content-type': 'application/json', 43 | 'fn-call-id': '01EY6NW519NG8G00GZJ000001Z', 44 | 'fn-deadline': '2021-02-10T19:15:19Z', 45 | 'accept-encoding': 'gzip', 46 | 'x-b3-traceid': trace_id, 47 | 'x-b3-spanid': span_id, 48 | 'x-b3-parentspanid': parent_span_id, 49 | 'x-b3-sampled': is_sampled, 50 | 'x-b3-flags': trace_flags 51 | } 52 | call_id = headers.get(constants.FN_CALL_ID) 53 | content_type = headers.get(constants.CONTENT_TYPE) 54 | deadline = headers.get(constants.FN_DEADLINE) 55 | method = headers.get(constants.FN_HTTP_METHOD) 56 | request_url = headers.get(constants.FN_HTTP_REQUEST_URL) 57 | 58 | 59 | def tracing_context(): 60 | return TracingContext( 61 | is_tracing_enabled, 62 | trace_collector_url, 63 | trace_id, 64 | span_id, 65 | parent_span_id, 66 | is_sampled, 67 | trace_flags 68 | ) 69 | 70 | 71 | def invoke_context(): 72 | return InvokeContext( 73 | app_id, app_name, fn_id, fn_name, call_id, 74 | content_type=content_type, 75 | deadline=deadline, 76 | config=os.environ, 77 | headers=headers, 78 | method=method, 79 | request_url=request_url, 80 | fn_format=constants.HTTPSTREAM, 81 | tracing_context=tracing_context(), 82 | ) 83 | 84 | 85 | def test_context_from_format_returns_correct_objects_when_tracing_enabled(): 86 | os.environ[constants.FN_APP_ID] = app_id 87 | os.environ[constants.FN_ID] = fn_id 88 | os.environ[constants.FN_APP_NAME] = app_name 89 | os.environ[constants.FN_NAME] = fn_name 90 | os.environ[constants.OCI_TRACE_COLLECTOR_URL] = trace_collector_url 91 | os.environ[constants.OCI_TRACING_ENABLED] = "1" 92 | 93 | mock_invoke_context = invoke_context() 94 | mock_data = "some_data" 95 | 96 | actual_invoke_context, data = context_from_format( 97 | constants.HTTPSTREAM, 98 | headers=headers, 99 | data=mock_data 100 | ) 101 | 102 | actual = actual_invoke_context.TracingContext().__dict__ 103 | expected = mock_invoke_context.TracingContext().__dict__ 104 | 105 | attrs = "_TracingContext__zipkin_attrs" 106 | filtered_actual = { 107 | key: value for (key, value) in actual.items() if key != attrs 108 | } 109 | filtered_expected = { 110 | key: value for (key, value) in expected.items() if key != attrs 111 | } 112 | 113 | assert filtered_actual == filtered_expected 114 | assert actual[attrs].trace_id == expected[attrs].trace_id 115 | assert actual[attrs].span_id != expected[attrs].span_id 116 | assert actual[attrs].span_id != span_id 117 | assert actual[attrs].parent_span_id == expected[attrs].parent_span_id 118 | assert actual[attrs].is_sampled == expected[attrs].is_sampled 119 | assert actual[attrs].flags == expected[attrs].flags 120 | 121 | assert data == mock_data 122 | 123 | os.environ.pop(constants.FN_APP_ID) 124 | os.environ.pop(constants.FN_ID) 125 | os.environ.pop(constants.FN_APP_NAME) 126 | os.environ.pop(constants.FN_NAME) 127 | os.environ.pop(constants.OCI_TRACE_COLLECTOR_URL) 128 | os.environ.pop(constants.OCI_TRACING_ENABLED) 129 | 130 | 131 | def test_context_from_format_returns_correct_objects_when_tracing_disabled(): 132 | os.environ[constants.FN_APP_ID] = app_id 133 | os.environ[constants.FN_ID] = fn_id 134 | os.environ[constants.FN_APP_NAME] = app_name 135 | os.environ[constants.FN_NAME] = fn_name 136 | os.environ[constants.OCI_TRACING_ENABLED] = "0" 137 | 138 | empty_tracing_context = TracingContext( 139 | False, None, None, None, None, None, None 140 | ) 141 | mock_data = "some_data" 142 | 143 | actual_invoke_context, data = context_from_format( 144 | constants.HTTPSTREAM, 145 | headers=headers, 146 | data=mock_data 147 | ) 148 | 149 | actual_tracing_context = actual_invoke_context.TracingContext().__dict__ 150 | assert actual_tracing_context == empty_tracing_context.__dict__ 151 | assert data == mock_data 152 | 153 | os.environ.pop(constants.FN_APP_ID) 154 | os.environ.pop(constants.FN_ID) 155 | os.environ.pop(constants.FN_APP_NAME) 156 | os.environ.pop(constants.FN_NAME) 157 | os.environ.pop(constants.OCI_TRACING_ENABLED) 158 | 159 | 160 | def test_zipkin_attrs(): 161 | mock_tracing_context = tracing_context() 162 | zipkin_attrs = mock_tracing_context.zipkin_attrs() 163 | 164 | assert zipkin_attrs.trace_id == trace_id 165 | assert zipkin_attrs.span_id != span_id 166 | assert zipkin_attrs.parent_span_id == span_id 167 | assert zipkin_attrs.is_sampled is True 168 | assert zipkin_attrs.flags == 0 169 | 170 | 171 | def test_tracing_context(): 172 | os.environ[constants.FN_APP_NAME] = app_name 173 | os.environ[constants.FN_NAME] = fn_name 174 | mock_tracing_context = tracing_context() 175 | 176 | assert mock_tracing_context.trace_collector_url() == trace_collector_url 177 | assert mock_tracing_context.trace_id() == trace_id 178 | assert mock_tracing_context.span_id() == span_id 179 | assert mock_tracing_context.parent_span_id() == parent_span_id 180 | assert mock_tracing_context.is_sampled() == bool(is_sampled) 181 | assert mock_tracing_context.flags() == trace_flags 182 | assert mock_tracing_context.service_name() == "mock_app::mock_fn" 183 | 184 | os.environ.pop(constants.FN_APP_NAME) 185 | os.environ.pop(constants.FN_NAME) 186 | -------------------------------------------------------------------------------- /fdk/version.py: -------------------------------------------------------------------------------- 1 | VERSION = '0.1.93' 2 | -------------------------------------------------------------------------------- /generate_func.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | 18 | 19 | set -xe 20 | 21 | PY_VERSION=$(python -c "import sys; print('{}.{}'.format(sys.version_info.major, sys.version_info.minor))") 22 | 23 | pip${PY_VERSION} install -r requirements.txt -r test-requirements.txt 24 | pip${PY_VERSION} install wheel 25 | 26 | python${PY_VERSION} setup.py bdist_wheel 27 | 28 | rm -fr test_function 29 | fn init --runtime python test_function || true 30 | 31 | echo "build_image: fnproject/python:${PY_VERSION}-dev" >> test_function/func.yaml 32 | echo "run_image: fnproject/python:${PY_VERSION}" >> test_function/func.yaml 33 | 34 | pip${PY_VERSION} download -r test_function/requirements.txt -d test_function/.pip_cache 35 | 36 | rm -fr test_function/.pip_cache/fdk* 37 | mv dist/fdk-* test_function/.pip_cache 38 | 39 | fn create app test-function || true 40 | pushd test_function && fn --verbose deploy --app test-function --local --no-bump; popd 41 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pbr>=5.11.1 2 | iso8601==0.1.12 3 | pytest>=5.4.3 4 | pytest-asyncio==0.12.0 5 | httptools>=0.5.0 6 | contextvars==2.4;python_version>="3.7" 7 | Cython>=3.0.0 8 | 9 | 10 | -------------------------------------------------------------------------------- /samples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fnproject/fdk-python/01c04ed039a437584958816f35956b3dbd53d841/samples/__init__.py -------------------------------------------------------------------------------- /samples/echo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fnproject/fdk-python/01c04ed039a437584958816f35956b3dbd53d841/samples/echo/__init__.py -------------------------------------------------------------------------------- /samples/echo/func.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import json 18 | import io 19 | import pytest 20 | 21 | from fdk import fixtures 22 | from fdk import response 23 | 24 | 25 | def handler(ctx, data: io.BytesIO = None): 26 | name = "World" 27 | try: 28 | body = json.loads(data.getvalue()) 29 | name = body.get("name") 30 | except (Exception, ValueError) as ex: 31 | print(str(ex)) 32 | pass 33 | 34 | return response.Response( 35 | ctx, status_code=202, response_data=json.dumps( 36 | {"message": "Hello {0}".format(name)}), 37 | headers={"Content-Type": "application/json"} 38 | ) 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_parse_request_without_data(): 43 | call = await fixtures.setup_fn_call(handler) 44 | 45 | content, status, headers = await call 46 | 47 | assert 202 == status 48 | assert {"message": "Hello World"} == json.loads(content) 49 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = fdk 3 | summary = Function Developer Kit for Python 4 | description = 5 | README.md 6 | description_content_type = text/markdown; charset=UTF-8; variant=GFM 7 | license = Apache License 2.0 8 | license_files = 9 | LICENSE 10 | THIRD_PARTY_LICENSES.txt 11 | NOTICE.txt 12 | AUTHORS 13 | author = Denis Makogon 14 | author-email = denys.makogon@oracle.com 15 | home-page = https://fnproject.github.io 16 | classifier = 17 | Intended Audience :: Information Technology 18 | Intended Audience :: System Administrators 19 | License :: OSI Approved :: Apache Software License 20 | Operating System :: POSIX :: Linux 21 | Programming Language :: Python 22 | Programming Language :: Python :: 3 23 | Programming Language :: Python :: 3.11 24 | 25 | [files] 26 | packages = 27 | fdk 28 | 29 | [global] 30 | setup-hooks = 31 | pbr.hooks.setup_hook 32 | 33 | [entry_points] 34 | console_scripts = 35 | fdk = fdk.scripts.fdk:main 36 | fdk-tcp-debug = fdk.scripts.fdk_tcp_debug:main 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2019, 2020 Oracle and/or its affiliates. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | import setuptools 18 | 19 | setuptools.setup(setup_requires=['pbr>=2.0.0'], 20 | pbr=True, 21 | packages=setuptools.find_packages( 22 | exclude=["internal", "internal.*"]) 23 | ) 24 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | flake8>=3.8.0 2 | hacking==4.0.0 3 | pytest>=5.4.3 4 | pytest-cov>=2.9.0 5 | attrs==19.1.0 6 | httptools>=0.5.0 7 | Cython==3.0.0 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{3.11,3.9,3.8},pep8 3 | skipsdist = True 4 | 5 | [testenv] 6 | basepython = 7 | pep8: python3.11 8 | py3.11: python3.11 9 | py3.9: python3.9 10 | py3.8: python3.8 11 | 12 | passenv = 13 | DOCKER_HOST 14 | API_URL 15 | setenv = VIRTUAL_ENV={envdir} 16 | usedevelop = True 17 | install_command = pip install -U {opts} {packages} 18 | deps = -r{toxinidir}/test-requirements.txt 19 | commands = find . -type f -name "*.pyc" -delete 20 | whitelist_externals = find 21 | rm 22 | go 23 | docker 24 | [testenv:pep8] 25 | commands = flake8 26 | 27 | [testenv:venv] 28 | commands = {posargs} 29 | 30 | [testenv:py3.11] 31 | commands = pytest -v -s --tb=long --cov=fdk {toxinidir}/fdk/tests 32 | 33 | [testenv:py3.9] 34 | commands = pytest -v -s --tb=long --cov=fdk {toxinidir}/fdk/tests 35 | 36 | [testenv:py3.8] 37 | commands = pytest -v -s --tb=long --cov=fdk {toxinidir}/fdk/tests 38 | 39 | [flake8] 40 | ignore = H405,H404,H403,H401,H306,H304,H101,E303,H301,W503 41 | show-source = True 42 | exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,build,docs,venv,.venv 43 | --------------------------------------------------------------------------------