[
  {
    "path": ".coveragerc",
    "content": "[run]\nomit = .venv/*"
  },
  {
    "path": ".github/workflows/python_package_build.yml",
    "content": "# This workflow will install dependencies, build Pyctuator, run tests (+coverage) and lint\n\nname: build\n\non:\n  push:\n  pull_request:\n\njobs:\n  run_image:\n    runs-on: [ubuntu-latest]\n    container:\n      image: matanrubin/python-poetry:3.9\n\n    env:\n      TEST_REDIS_SERVER: True\n      REDIS_HOST: redis\n\n    services:\n      # Use a redis container for testing the redis health-provider\n      redis:\n        image: redis:5.0.3\n\n    steps:\n      - uses: actions/checkout@v2\n      - run: make bootstrap\n      - run: poetry build -vvv\n\n      # Install all dependencies except for psutil and run the tests with coverage - this tests handling missing psutil\n      - run: poetry install --extras flask --extras fastapi --extras aiohttp --extras tornado --extras db --extras redis\n      - run: make coverage\n\n      # Run pylint+mypy after installing psutil so they don't complain on missing dependencies\n      - run: poetry install --extras psutil\n      - run: make check\n\n      # Run tests with coverage again - this adds tests that require psutil\n      - run: make coverage\n\n      # Upload coverage files to codecov\n      - uses: actions/upload-artifact@v2\n        with:\n          name: htmlcov.zip\n          path: htmlcov/\n      - uses: codecov/codecov-action@v1\n"
  },
  {
    "path": ".github/workflows/python_package_publish.yml",
    "content": "# This workflow will build Pyctuator, and publish it to pypi.org\n\nname: publish\n\non:\n  release:\n    types: [published]\n\njobs:\n  run_image:\n    runs-on: [ubuntu-latest]\n    container:\n      image: matanrubin/python-poetry:3.9\n\n    steps:\n      - uses: actions/checkout@v2\n      - run: make bootstrap\n      - run: poetry update -vvv\n      - run: poetry build -vvv\n      - run: poetry publish --username __token__ --password ${{ secrets.PYPI_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Created by .ignore support plugin (hsz.mobi)\n### macOS template\n# General\n.DS_Store\n.AppleDouble\n.LSOverride\n\n# Icon must end with two \\r\nIcon\n\n# Thumbnails\n._*\n\n# Files that might appear in the root of a volume\n.DocumentRevisions-V100\n.fseventsd\n.Spotlight-V100\n.TemporaryItems\n.Trashes\n.VolumeIcon.icns\n.com.apple.timemachine.donotpresent\n\n# Directories potentially created on remote AFP share\n.AppleDB\n.AppleDesktop\nNetwork Trash Folder\nTemporary Items\n.apdisk\n### Java template\n# Compiled class file\n*.class\n\n# Log file\n*.log\n\n# BlueJ files\n*.ctxt\n\n# Mobile Tools for Java (J2ME)\n.mtj.tmp/\n\n# Package Files #\n*.jar\n*.war\n*.nar\n*.ear\n*.zip\n*.tar.gz\n*.rar\n\n# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml\nhs_err_pid*\n### Maven template\ntarget/\npom.xml.tag\npom.xml.releaseBackup\npom.xml.versionsBackup\npom.xml.next\nrelease.properties\ndependency-reduced-pom.xml\nbuildNumber.properties\n.mvn/timing.properties\n.mvn/wrapper/maven-wrapper.jar\n### Python template\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n.hypothesis/\n.pytest_cache/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# pyenv\n.python-version\n\n# celery beat schedule file\ncelerybeat-schedule\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n### JetBrains template\n# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm\n# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839\n\n# User-specific stuff\n.idea/**/workspace.xml\n.idea/**/tasks.xml\n.idea/**/usage.statistics.xml\n.idea/**/dictionaries\n.idea/**/shelf\n\n# Sensitive or high-churn files\n.idea/**/dataSources/\n.idea/**/dataSources.ids\n.idea/**/dataSources.local.xml\n.idea/**/sqlDataSources.xml\n.idea/**/dynamic.xml\n.idea/**/uiDesigner.xml\n.idea/**/dbnavigator.xml\n\n# Gradle\n.idea/**/gradle.xml\n.idea/**/libraries\n\n# Gradle and Maven with auto-import\n# When using Gradle or Maven with auto-import, you should exclude module files,\n# since they will be recreated, and may cause churn.  Uncomment if using\n# auto-import.\n# .idea/modules.xml\n# .idea/*.iml\n# .idea/modules\n\n# CMake\ncmake-build-*/\n\n# Mongo Explorer plugin\n.idea/**/mongoSettings.xml\n\n# File-based project format\n*.iws\n\n# IntelliJ\nout/\n\n# mpeltonen/sbt-idea plugin\n.idea_modules/\n\n# JIRA plugin\natlassian-ide-plugin.xml\n\n# Cursive Clojure plugin\n.idea/replstate.xml\n\n# Crashlytics plugin (for Android Studio and IntelliJ)\ncom_crashlytics_export_strings.xml\ncrashlytics.properties\ncrashlytics-build.properties\nfabric.properties\n\n# Editor-based Rest Client\n.idea/httpRequests\n### Eclipse template\n\n.metadata\nbin/\ntmp/\n*.tmp\n*.bak\n*.swp\n*~.nib\nlocal.properties\n.settings/\n.loadpath\n.recommenders\n\n# External tool builders\n.externalToolBuilders/\n\n# Locally stored \"Eclipse launch configurations\"\n*.launch\n\n# PyDev specific (Python IDE for Eclipse)\n*.pydevproject\n\n# CDT-specific (C/C++ Development Tooling)\n.cproject\n\n# CDT- autotools\n.autotools\n\n# Java annotation processor (APT)\n.factorypath\n\n# PDT-specific (PHP Development Tools)\n.buildpath\n\n# sbteclipse plugin\n.target\n\n# Tern plugin\n.tern-project\n\n# TeXlipse plugin\n.texlipse\n\n# STS (Spring Tool Suite)\n.springBeans\n\n# Code Recommenders\n.recommenders/\n\n# Annotation Processing\n.apt_generated/\n\n# Scala IDE specific (Scala & Java development for Eclipse)\n.cache-main\n.scala_dependencies\n.worksheet\n### VisualStudio template\n## Ignore Visual Studio temporary files, build results, and\n## files generated by popular Visual Studio add-ons.\n##\n## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore\n\n# User-specific files\n*.suo\n*.user\n*.userosscache\n*.sln.docstates\n\n# User-specific files (MonoDevelop/Xamarin Studio)\n*.userprefs\n\n# Build results\n[Dd]ebug/\n[Dd]ebugPublic/\n[Rr]elease/\n[Rr]eleases/\nx64/\nx86/\nbld/\n[Bb]in/\n[Oo]bj/\n[Ll]og/\n\n# Visual Studio 2015/2017 cache/options directory\n.vs/\n# Uncomment if you have tasks that create the project's static files in wwwroot\n#wwwroot/\n\n# Visual Studio 2017 auto generated files\nGenerated\\ Files/\n\n# MSTest test Results\n[Tt]est[Rr]esult*/\n[Bb]uild[Ll]og.*\n\n# NUNIT\n*.VisualState.xml\nTestResult.xml\n\n# Build Results of an ATL Project\n[Dd]ebugPS/\n[Rr]eleasePS/\ndlldata.c\n\n# Benchmark Results\nBenchmarkDotNet.Artifacts/\n\n# .NET Core\nproject.lock.json\nproject.fragment.lock.json\nartifacts/\n\n# StyleCop\nStyleCopReport.xml\n\n# Files built by Visual Studio\n*_i.c\n*_p.c\n*_i.h\n*.ilk\n*.meta\n*.obj\n*.iobj\n*.pch\n*.pdb\n*.ipdb\n*.pgc\n*.pgd\n*.rsp\n*.sbr\n*.tlb\n*.tli\n*.tlh\n*.tmp\n*.tmp_proj\n*.log\n*.vspscc\n*.vssscc\n.builds\n*.pidb\n*.svclog\n*.scc\n\n# Chutzpah Test files\n_Chutzpah*\n\n# Visual C++ cache files\nipch/\n*.aps\n*.ncb\n*.opendb\n*.opensdf\n*.sdf\n*.cachefile\n*.VC.db\n*.VC.VC.opendb\n\n# Visual Studio profiler\n*.psess\n*.vsp\n*.vspx\n*.sap\n\n# Visual Studio Trace Files\n*.e2e\n\n# TFS 2012 Local Workspace\n$tf/\n\n# Guidance Automation Toolkit\n*.gpState\n\n# ReSharper is a .NET coding add-in\n_ReSharper*/\n*.[Rr]e[Ss]harper\n*.DotSettings.user\n\n# JustCode is a .NET coding add-in\n.JustCode\n\n# TeamCity is a build add-in\n_TeamCity*\n\n# DotCover is a Code Coverage Tool\n*.dotCover\n\n# AxoCover is a Code Coverage Tool\n.axoCover/*\n!.axoCover/settings.json\n\n# Visual Studio code coverage results\n*.coverage\n*.coveragexml\n\n# NCrunch\n_NCrunch_*\n.*crunch*.local.xml\nnCrunchTemp_*\n\n# MightyMoose\n*.mm.*\nAutoTest.Net/\n\n# Web workbench (sass)\n.sass-cache/\n\n# Installshield output folder\n[Ee]xpress/\n\n# DocProject is a documentation generator add-in\nDocProject/buildhelp/\nDocProject/Help/*.HxT\nDocProject/Help/*.HxC\nDocProject/Help/*.hhc\nDocProject/Help/*.hhk\nDocProject/Help/*.hhp\nDocProject/Help/Html2\nDocProject/Help/html\n\n# Click-Once directory\npublish/\n\n# Publish Web Output\n*.[Pp]ublish.xml\n*.azurePubxml\n# Note: Comment the next line if you want to checkin your web deploy settings,\n# but database connection strings (with potential passwords) will be unencrypted\n*.pubxml\n*.publishproj\n\n# Microsoft Azure Web App publish settings. Comment the next line if you want to\n# checkin your Azure Web App publish settings, but sensitive information contained\n# in these scripts will be unencrypted\nPublishScripts/\n\n# NuGet Packages\n*.nupkg\n# The packages folder can be ignored because of Package Restore\n**/[Pp]ackages/*\n# except build/, which is used as an MSBuild target.\n!**/[Pp]ackages/build/\n# Uncomment if necessary however generally it will be regenerated when needed\n#!**/[Pp]ackages/repositories.config\n# NuGet v3's project.json files produces more ignorable files\n*.nuget.props\n*.nuget.targets\n\n# Microsoft Azure Build Output\ncsx/\n*.build.csdef\n\n# Microsoft Azure Emulator\necf/\nrcf/\n\n# Windows Store app package directories and files\nAppPackages/\nBundleArtifacts/\nPackage.StoreAssociation.xml\n_pkginfo.txt\n*.appx\n\n# Visual Studio cache files\n# files ending in .cache can be ignored\n*.[Cc]ache\n# but keep track of directories ending in .cache\n!*.[Cc]ache/\n\n# Others\nClientBin/\n~$*\n*~\n*.dbmdl\n*.dbproj.schemaview\n*.jfm\n*.pfx\n*.publishsettings\norleans.codegen.cs\n\n# Including strong name files can present a security risk\n# (https://github.com/github/gitignore/pull/2483#issue-259490424)\n#*.snk\n\n# Since there are multiple workflows, uncomment next line to ignore bower_components\n# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)\n#bower_components/\n\n# RIA/Silverlight projects\nGenerated_Code/\n\n# Backup & report files from converting an old project file\n# to a newer Visual Studio version. Backup files are not needed,\n# because we have git ;-)\n_UpgradeReport_Files/\nBackup*/\nUpgradeLog*.XML\nUpgradeLog*.htm\nServiceFabricBackup/\n*.rptproj.bak\n\n# SQL Server files\n*.mdf\n*.ldf\n*.ndf\n\n# Business Intelligence projects\n*.rdl.data\n*.bim.layout\n*.bim_*.settings\n*.rptproj.rsuser\n\n# Microsoft Fakes\nFakesAssemblies/\n\n# GhostDoc plugin setting file\n*.GhostDoc.xml\n\n# Node.js Tools for Visual Studio\n.ntvs_analysis.dat\nnode_modules/\n\n# Visual Studio 6 build log\n*.plg\n\n# Visual Studio 6 workspace options file\n*.opt\n\n# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)\n*.vbw\n\n# Visual Studio LightSwitch build output\n**/*.HTMLClient/GeneratedArtifacts\n**/*.DesktopClient/GeneratedArtifacts\n**/*.DesktopClient/ModelManifest.xml\n**/*.Server/GeneratedArtifacts\n**/*.Server/ModelManifest.xml\n_Pvt_Extensions\n\n# Paket dependency manager\n.paket/paket.exe\npaket-files/\n\n# FAKE - F# Make\n.fake/\n\n# JetBrains Rider\n.idea/\n*.sln.iml\n\n# CodeRush\n.cr/\n\n# Python Tools for Visual Studio (PTVS)\n__pycache__/\n*.pyc\n\n# Visual Studio Code\n.vscode/\n\n# Cake - Uncomment if you are using it\n# tools/**\n# !tools/packages.config\n\n# Tabs Studio\n*.tss\n\n# Telerik's JustMock configuration file\n*.jmconfig\n\n# BizTalk build output\n*.btp.cs\n*.btm.cs\n*.odx.cs\n*.xsd.cs\n\n# OpenCover UI analysis results\nOpenCover/\n\n# Azure Stream Analytics local run output\nASALocalRun/\n\n# MSBuild Binary and Structured Log\n*.binlog\n\n# NVidia Nsight GPU debugger configuration file\n*.nvuser\n\n# MFractors (Xamarin productivity tool) working folder\n.mfractor/\n### Windows template\n# Windows thumbnail cache files\nThumbs.db\nehthumbs.db\nehthumbs_vista.db\n\n# Dump file\n*.stackdump\n\n# Folder config file\n[Dd]esktop.ini\n\n# Recycle Bin used on file shares\n$RECYCLE.BIN/\n\n# Windows Installer files\n*.cab\n*.msi\n*.msix\n*.msm\n*.msp\n\n# Windows shortcuts\n*.lnk\n### Kotlin template\n# Compiled class file\n*.class\n\n# Log file\n*.log\n\n# BlueJ files\n*.ctxt\n\n# Mobile Tools for Java (J2ME)\n.mtj.tmp/\n\n# Package Files #\n*.jar\n*.war\n*.nar\n*.ear\n*.zip\n*.tar.gz\n*.rar\n\n# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml\nhs_err_pid*\n### VirtualEnv template\n# Virtualenv\n# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/\n.Python\n[Bb]in\n[Ii]nclude\n[Ll]ib\n[Ll]ib64\n[Ll]ocal\n[Ss]cripts\npyvenv.cfg\n.venv\npip-selfcheck.json\n\n"
  },
  {
    "path": ".pylintrc",
    "content": "[MASTER]\n\n# Add files or directories to the blacklist. They should be base names, not\n# paths.\nignore=CVS\n\n# Pickle collected data for later comparisons.\npersistent=yes\n\n[MESSAGES CONTROL]\n\n# Only show warnings with the listed confidence levels. Leave empty to show\n# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED.\nconfidence=\n\n# Disable the message, report, category or checker with the given id(s). You\n# can either give multiple identifiers separated by comma (,) or put this\n# option multiple times (only on the command line, not in the configuration\n# file where it should appear only once). You can also use \"--disable=all\" to\n# disable everything first and then reenable specific checks. For example, if\n# you want to run only the similarities checker, you can use \"--disable=all\n# --enable=similarities\". If you want to run only the classes checker, but have\n# no Warning level messages displayed, use \"--disable=all --enable=classes\n# --disable=W\".\ndisable=\n        # Pylint Defaults\n        raw-checker-failed,\n        bad-inline-option,\n        locally-disabled,\n        file-ignored,\n        suppressed-message,\n        useless-suppression,\n        deprecated-pragma,\n        use-symbolic-message-instead,\n        missing-docstring,\n        logging-fstring-interpolation,\n        invalid-name,\n        no-member, # Pylint doesn't currently support subclassing Enum and issues this warning everywhere\n        duplicate-code, # in order to support the domain/db/api duplication such as with InverterSpec\n        too-few-public-methods,\n        too-many-arguments,\n        redefined-outer-name, # false positive on pytest fixtures\n\n\n# Enable the message, report, category or checker with the given id(s). You can\n# either give multiple identifier separated by comma (,) or put this option\n# multiple time (only on the command line, not in the configuration file where\n# it should appear only once). See also the \"--disable\" option for examples.\nenable=c-extension-no-member\n\n\n[REPORTS]\n\n# Python expression which should return a note less than 10 (10 is the highest\n# note). You have access to the variables errors warning, statement which\n# respectively contain the number of errors / warnings messages and the total\n# number of statements analyzed. This is used by the global evaluation report\n# (RP0004).\nevaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)\n\n# Template used to display messages. This is a python new-style format string\n# used to format the message information. See doc for all details.\n#msg-template=\n\n# Set the output format. Available formats are text, parseable, colorized, json\n# and msvs (visual studio). You can also give a reporter class, e.g.\n# mypackage.mymodule.MyReporterClass.\noutput-format=text\n\n# Tells whether to display a full report or only the messages.\nreports=no\n\n# Activate the evaluation score.\nscore=yes\n\n\n[REFACTORING]\n\n# Maximum number of nested blocks for function / method body\nmax-nested-blocks=5\n\n# Complete name of functions that never returns. When checking for\n# inconsistent-return-statements if a never returning function is called then\n# it will be considered as an explicit return statement and no message will be\n# printed.\nnever-returning-functions=sys.exit\n\n\n[LOGGING]\n\n# Format style used to check logging format string. `old` means using %\n# formatting, while `new` is for `{}` formatting.\nlogging-format-style=old\n\n# Logging modules to check that the string format arguments are in logging\n# function parameter format.\nlogging-modules=logging\n\n\n[SPELLING]\n\n# Limits count of emitted suggestions for spelling mistakes.\nmax-spelling-suggestions=4\n\n# Spelling dictionary name. Available dictionaries: none. To make it working\n# install python-enchant package..\nspelling-dict=\n\n# List of comma separated words that should not be checked.\nspelling-ignore-words=\n\n# A path to a file that contains private dictionary; one word per line.\nspelling-private-dict-file=\n\n# Tells whether to store unknown words to indicated private dictionary in\n# --spelling-private-dict-file option instead of raising a message.\nspelling-store-unknown-words=no\n\n\n[MISCELLANEOUS]\n\n# List of note tags to take in consideration, separated by a comma.\nnotes=FIXME,\n      XXX,\n      TODO\n\n\n[TYPECHECK]\n\n# List of decorators that produce context managers, such as\n# contextlib.contextmanager. Add to this list to register other decorators that\n# produce valid context managers.\ncontextmanager-decorators=contextlib.contextmanager\n\n# List of members which are set dynamically and missed by pylint inference\n# system, and so shouldn't trigger E1101 when accessed. Python regular\n# expressions are accepted.\ngenerated-members=\n\n# Tells whether missing members accessed in mixin class should be ignored. A\n# mixin class is detected if its name ends with \"mixin\" (case insensitive).\nignore-mixin-members=yes\n\n# Tells whether to warn about missing members when the owner of the attribute\n# is inferred to be None.\nignore-none=yes\n\n# This flag controls whether pylint should warn about no-member and similar\n# checks whenever an opaque object is returned when inferring. The inference\n# can return multiple potential results while evaluating a Python object, but\n# some branches might not be evaluated, which results in partial inference. In\n# that case, it might be useful to still emit no-member and other checks for\n# the rest of the inferred objects.\nignore-on-opaque-inference=yes\n\n# List of class names for which member attributes should not be checked (useful\n# for classes with dynamically set attributes). This supports the use of\n# qualified names.\nignored-classes=optparse.Values,thread._local,_thread._local\n\n# List of module names for which member attributes should not be checked\n# (useful for modules/projects where namespaces are manipulated during runtime\n# and thus existing member attributes cannot be deduced by static analysis. It\n# supports qualified module names, as well as Unix pattern matching.\nignored-modules=\n\n# Show a hint with possible names when a member name was not found. The aspect\n# of finding the hint is based on edit distance.\nmissing-member-hint=yes\n\n# The minimum edit distance a name should have in order to be considered a\n# similar match for a missing member name.\nmissing-member-hint-distance=1\n\n# The total number of similar names that should be taken in consideration when\n# showing a hint for a missing member.\nmissing-member-max-choices=1\n\n\n[VARIABLES]\n\n# List of additional names supposed to be defined in builtins. Remember that\n# you should avoid defining new builtins when possible.\nadditional-builtins=\n\n# Tells whether unused global variables should be treated as a violation.\nallow-global-unused-variables=yes\n\n# List of strings which can identify a callback function by name. A callback\n# name must start or end with one of those strings.\ncallbacks=cb_,\n          _cb\n\n# A regular expression matching the name of dummy variables (i.e. expected to\n# not be used).\ndummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_\n\n# Argument names that match this expression will be ignored. Default to name\n# with leading underscore.\nignored-argument-names=_.*|^ignored_|^unused_\n\n# Tells whether we should check for unused import in __init__ files.\ninit-import=no\n\n# List of qualified module names which can have objects that can redefine\n# builtins.\nredefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io\n\n\n[FORMAT]\n\n# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.\nexpected-line-ending-format=\n\n# Regexp for a line that is allowed to be longer than the limit.\nignore-long-lines=^\\s*(# )?<?https?://\\S+>?$\n\n# Number of spaces of indent required inside a hanging or continued line.\nindent-after-paren=4\n\n# String used as indentation unit. This is usually \"    \" (4 spaces) or \"\\t\" (1\n# tab).\nindent-string='    '\n\n# Maximum number of characters on a single line.\nmax-line-length=120\n\n# Maximum number of lines in a module.\nmax-module-lines=1000\n\n# List of optional constructs for which whitespace checking is disabled. `dict-\n# separator` is used to allow tabulation in dicts, etc.: {1  : 1,\\n222: 2}.\n# `trailing-comma` allows a space between comma and closing bracket: (a, ).\n# `empty-line` allows space-only lines.\n# no-space-check=trailing-comma,\n#               dict-separator\n\n# Allow the body of a class to be on the same line as the declaration if body\n# contains single statement.\nsingle-line-class-stmt=no\n\n# Allow the body of an if to be on the same line as the test if there is no\n# else.\nsingle-line-if-stmt=no\n\n\n[SIMILARITIES]\n\n# Ignore comments when computing similarities.\nignore-comments=yes\n\n# Ignore docstrings when computing similarities.\nignore-docstrings=yes\n\n# Ignore imports when computing similarities.\nignore-imports=no\n\n# Minimum lines number of a similarity.\nmin-similarity-lines=4\n\n\n[BASIC]\n\n# Naming style matching correct argument names.\nargument-naming-style=snake_case\n\n# Regular expression matching correct argument names. Overrides argument-\n# naming-style.\n#argument-rgx=\n\n# Naming style matching correct attribute names.\nattr-naming-style=snake_case\n\n# Regular expression matching correct attribute names. Overrides attr-naming-\n# style.\n#attr-rgx=\n\n# Bad variable names which should always be refused, separated by a comma.\nbad-names=foo,\n          bar,\n          baz,\n          toto,\n          tutu,\n          tata\n\n# Naming style matching correct class attribute names.\nclass-attribute-naming-style=any\n\n# Regular expression matching correct class attribute names. Overrides class-\n# attribute-naming-style.\n#class-attribute-rgx=\n\n# Naming style matching correct class names.\nclass-naming-style=PascalCase\n\n# Regular expression matching correct class names. Overrides class-naming-\n# style.\n#class-rgx=\n\n# Naming style matching correct constant names.\nconst-naming-style=UPPER_CASE\n\n# Regular expression matching correct constant names. Overrides const-naming-\n# style.\n#const-rgx=\n\n# Minimum line length for functions/classes that require docstrings, shorter\n# ones are exempt.\ndocstring-min-length=-1\n\n# Naming style matching correct function names.\nfunction-naming-style=snake_case\n\n# Regular expression matching correct function names. Overrides function-\n# naming-style.\n#function-rgx=\n\n# Good variable names which should always be accepted, separated by a comma.\ngood-names=i,\n           j,\n           k,\n           ex,\n           Run,\n           _\n\n# Include a hint for the correct naming format with invalid-name.\ninclude-naming-hint=no\n\n# Naming style matching correct inline iteration names.\ninlinevar-naming-style=any\n\n# Regular expression matching correct inline iteration names. Overrides\n# inlinevar-naming-style.\n#inlinevar-rgx=\n\n# Naming style matching correct method names.\nmethod-naming-style=snake_case\n\n# Regular expression matching correct method names. Overrides method-naming-\n# style.\n#method-rgx=\n\n# Naming style matching correct module names.\nmodule-naming-style=snake_case\n\n# Regular expression matching correct module names. Overrides module-naming-\n# style.\n#module-rgx=\n\n# Colon-delimited sets of names that determine each other's naming style when\n# the name regexes allow several styles.\nname-group=\n\n# Regular expression which should only match function or class names that do\n# not require a docstring.\nno-docstring-rgx=^_\n\n# List of decorators that produce properties, such as abc.abstractproperty. Add\n# to this list to register other decorators that produce valid properties.\n# These decorators are taken in consideration only for invalid-name.\nproperty-classes=abc.abstractproperty\n\n# Naming style matching correct variable names.\nvariable-naming-style=snake_case\n\n# Regular expression matching correct variable names. Overrides variable-\n# naming-style.\n#variable-rgx=\n\n\n[STRING]\n\n# This flag controls whether the implicit-str-concat-in-sequence should\n# generate a warning on implicit string concatenation in sequences defined over\n# several lines.\ncheck-str-concat-over-line-jumps=no\n\n\n[IMPORTS]\n\n# Allow wildcard imports from modules that define __all__.\nallow-wildcard-with-all=no\n\n# Analyse import fallback blocks. This can be used to support both Python 2 and\n# 3 compatible code, which means that the block might have code that exists\n# only in one or another interpreter, leading to false positives when analysed.\nanalyse-fallback-blocks=no\n\n# Deprecated modules which should not be used, separated by a comma.\ndeprecated-modules=optparse,tkinter.tix\n\n# Create a graph of external dependencies in the given file (report RP0402 must\n# not be disabled).\next-import-graph=\n\n# Create a graph of every (i.e. internal and external) dependencies in the\n# given file (report RP0402 must not be disabled).\nimport-graph=\n\n# Create a graph of internal dependencies in the given file (report RP0402 must\n# not be disabled).\nint-import-graph=\n\n# Force import order to recognize a module as part of the standard\n# compatibility libraries.\nknown-standard-library=\n\n# Force import order to recognize a module as part of a third party library.\nknown-third-party=enchant\n\n\n[CLASSES]\n\n# List of method names used to declare (i.e. assign) instance attributes.\ndefining-attr-methods=__init__,\n                      __new__,\n                      setUp\n\n# List of member names, which should be excluded from the protected access\n# warning.\nexclude-protected=_asdict,\n                  _fields,\n                  _replace,\n                  _source,\n                  _make\n\n# List of valid names for the first argument in a class method.\nvalid-classmethod-first-arg=cls\n\n# List of valid names for the first argument in a metaclass class method.\nvalid-metaclass-classmethod-first-arg=cls\n\n\n[DESIGN]\n\n# Maximum number of arguments for function / method.\nmax-args=5\n\n# Maximum number of attributes for a class (see R0902).\nmax-attributes=7\n\n# Maximum number of boolean expressions in an if statement.\nmax-bool-expr=5\n\n# Maximum number of branch for function / method body.\nmax-branches=12\n\n# Maximum number of locals for function / method body.\nmax-locals=15\n\n# Maximum number of parents for a class (see R0901).\nmax-parents=7\n\n# Maximum number of public methods for a class (see R0904).\nmax-public-methods=20\n\n# Maximum number of return / yield for function / method body.\nmax-returns=6\n\n# Maximum number of statements in function / method body.\nmax-statements=50\n\n# Minimum number of public methods for a class (see R0903).\nmin-public-methods=2\n\n\n[EXCEPTIONS]\n\n# Exceptions that will emit a warning when being caught. Defaults to\n# \"BaseException, Exception\".\novergeneral-exceptions=builtins.BaseException,\n                       builtins.Exception\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"{}\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2020 SolarEdge Technologies Ltd.\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "Makefile",
    "content": "\nall: check\n\nhelp:\n\t@echo \"Available targets:\"\n\t@echo \"- help                   Show this help message\"\n\t@echo \"- bootstrap              Installs required dependencies\"\n\t@echo \"- check                  Runs static code analyzers\"\n\t@echo \"- test                   Run unit tests\"\n\t@echo \"- coverage               Check test coverage\"\n\nbootstrap:\n\tpoetry --version || curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python\n\tpoetry install\n\ncheck: pylint mypy\n\ntest:\n\tpoetry run pytest --log-cli-level=4 -vv tests\n\ncoverage:\n\tpoetry run pytest --cov-append --cov-report xml:./coverage.xml --cov-report html --cov-report term --cov=pyctuator --log-cli-level=4 -vv tests\n\npylint:\n\tpoetry run pylint --exit-zero pyctuator tests\n\nmypy:\n\tpoetry run pip install types-redis\n\tpoetry run pip install types-requests\n\tpoetry run mypy -p pyctuator -p tests\n\npackage:\n\tpoetry build\n\t\nclean:\n\tfind  . -type d -name __pycache__ -print | xargs rm -rf\n\tfind  . -type d -name .pytest_cache -print | xargs rm -rf\n\trm -rf dist htmlcov .mypy_cache\n\n.PHONY: all help bootstrap check test coverage pylint mypy package clean\n"
  },
  {
    "path": "README.md",
    "content": "[![PyPI](https://img.shields.io/pypi/v/pyctuator?color=green&style=plastic)](https://pypi.org/project/pyctuator/)\n[![build](https://github.com/SolarEdgeTech/pyctuator/workflows/build/badge.svg)](https://github.com/SolarEdgeTech/pyctuator/)\n[![Codecov](https://img.shields.io/codecov/c/github/SolarEdgeTech/pyctuator?style=plastic)](https://codecov.io/gh/SolarEdgeTech/pyctuator)\n\n# Pyctuator\n\nMonitor Python web apps using \n[Spring Boot Admin](https://github.com/codecentric/spring-boot-admin). \n\nPyctuator supports **[Flask](https://palletsprojects.com/p/flask/)**, **[FastAPI](https://fastapi.tiangolo.com/)**, **[aiohttp](https://docs.aiohttp.org/)** and **[Tornado](https://www.tornadoweb.org/)**. **Django** support is planned as well.\n\nThe following video shows a FastAPI web app being monitored and controled using Spring Boot Admin.\n \n![Pyctuator Example](examples/images/Pyctuator_Screencast.gif)\n\nThe complete example can be found in [Advanced example](examples/Advanced/README.md).\n\n## Requirements\nPython 3.9+\n\nPyctuator has zero hard dependencies.\n\n## Installing\nInstall Pyctuator using pip: `pip3 install pyctuator`\n\n## Why?\nMany Java shops use Spring Boot as their main web framework for developing\nmicroservices. \nThese organizations often use Spring Actuator together with Spring Boot Admin\nto monitor their microservices' status, gain access to applications'\n state and configuration, manipulate log levels, etc.\n \nWhile Spring Boot is suitable for many use-cases, it is very common for organizations \nto also have a couple of Python microservices, as Python is often more suitable for \nsome types of applications. The most common examples are Data Science and Machine Learning\napplications.\n\nSetting up a proper monitoring tool for these microservices is a complex task, and might\nnot be justified for just a few Python microservices in a sea of Java microservices.\n\nThis is where Pyctuator comes in. It allows you to easily integrate your Python\nmicroservices into your existing Spring Boot Admin deployment.\n\n## Main Features\nPyctuator is a partial Python implementation of the \n[Spring Actuator API](https://docs.spring.io/spring-boot/docs/2.1.8.RELEASE/actuator-api/html/)  . \n\nIt currently supports the following Actuator features:\n\n* **Application details**\n* **Metrics**\n    * Memory usage\n    * Disk usage \n    * Custom metrics\n* **Health monitors**\n    * Built in MySQL health monitor\n    * Built in Redis health monitor\n    * Custom health monitors\n* **Environment**\n* **Loggers** - Easily change log levels during runtime\n* **Log file** - Tail the application's log file\n* **Thread dump** - See which threads are running\n* **HTTP traces** - Tail recent HTTP requests, including status codes and latency\n\n## Quickstart\nThe examples below show a minimal integration of **FastAPI**, **Flask** and **aiohttp** applications with **Pyctuator**.\n\nAfter installing Flask/FastAPI/aiohttp and Pyctuator, start by launching a local Spring Boot Admin instance:\n\n```sh\ndocker run --rm -p 8080:8080 --add-host=host.docker.internal:host-gateway michayaak/spring-boot-admin:2.2.3-1\n```\n\nThen go to `http://localhost:8080` to get to the web UI.\n\n### Flask\nThe following example is complete and should run as is.\n\n```python\nfrom flask import Flask\nfrom pyctuator.pyctuator import Pyctuator\n\napp_name = \"Flask App with Pyctuator\"\napp = Flask(app_name)\n\n\n@app.route(\"/\")\ndef hello():\n    return \"Hello World!\"\n\n\nPyctuator(\n    app,\n    app_name,\n    app_url=\"http://host.docker.internal:5000\",\n    pyctuator_endpoint_url=\"http://host.docker.internal:5000/pyctuator\",\n    registration_url=\"http://localhost:8080/instances\"\n)\n\napp.run(debug=False, port=5000)\n```\n\nThe application will automatically register with Spring Boot Admin upon start up.\n\nLog in to the Spring Boot Admin UI at `http://localhost:8080` to interact with the application. \n\n### FastAPI\nThe following example is complete and should run as is.\n\n```python\nfrom fastapi import FastAPI\nfrom uvicorn import Server\n\nfrom uvicorn.config import Config\nfrom pyctuator.pyctuator import Pyctuator\n\n\napp_name = \"FastAPI App with Pyctuator\"\napp = FastAPI(title=app_name)\n\n\n@app.get(\"/\")\ndef hello():\n    return \"Hello World!\"\n\n\nPyctuator(\n    app,\n    \"FastAPI Pyctuator\",\n    app_url=\"http://host.docker.internal:8000\",\n    pyctuator_endpoint_url=\"http://host.docker.internal:8000/pyctuator\",\n    registration_url=\"http://localhost:8080/instances\"\n)\n\nServer(config=(Config(app=app, loop=\"asyncio\"))).run()\n```\n\nThe application will automatically register with Spring Boot Admin upon start up.\n\nLog in to the Spring Boot Admin UI at `http://localhost:8080` to interact with the application. \n\n### aiohttp\nThe following example is complete and should run as is.\n\n```python\nfrom aiohttp import web\nfrom pyctuator.pyctuator import Pyctuator\n\napp = web.Application()\nroutes = web.RouteTableDef()\n\n@routes.get(\"/\")\ndef hello():\n    return web.Response(text=\"Hello World!\")\n\nPyctuator(\n    app,\n    \"aiohttp Pyctuator\",\n    app_url=\"http://host.docker.internal:8888\",\n    pyctuator_endpoint_url=\"http://host.docker.internal:8888/pyctuator\",\n    registration_url=\"http://localhost:8080/instances\"\n)\n\napp.add_routes(routes)\nweb.run_app(app, port=8888)\n```\n\nThe application will automatically register with Spring Boot Admin upon start up.\n\nLog in to the Spring Boot Admin UI at `http://localhost:8080` to interact with the application.\n\n### Registration Notes\nWhen registering a service in Spring Boot Admin, note that:\n* **Docker** - If the Spring Boot Admin is running in a container while the managed service is running in the docker-host directly, the `app_url` and `pyctuator_endpoint_url` should use `host.docker.internal` as the url's host so Spring Boot Admin will be able to connect to the monitored service.\n* **Http Traces** - In order for the \"Http Traces\" tab to be able to hide requests sent by Spring Boot Admin to the Pyctuator endpoint, `pyctuator_endpoint_url` must be using the same host and port as `app_url`.\n* **HTTPS** - If Pyctuator is to be registered with Spring Boot Admin using HTTPS and the default SSL context is inappropriate, you can provide your own `ssl.SSLContext` using the `ssl_context` optional parameter of the `Pyctuator` constructor.\n* **Insecure HTTPS** - If Spring Boot Admin is using HTTPS with self-signed certificate, set the `PYCTUATOR_REGISTRATION_NO_CERT` environment variable so Pyctuator will disable certificate validation when registering (and deregistering).\n\n## Advanced Configuration\nThe following sections are intended for advanced users who want to configure advanced Pyctuator features.\n\n### Application Info\nWhile Pyctuator only needs to know the application's name, we recommend that applications monitored by Spring \nBoot Admin will show additional build and git details. \nThis becomes handy when scaling out a service to multiple instances by showing the version of each instance.\nTo do so, you can provide additional build and git info using methods of the Pyctuator object:\n\n```python\npyctuator = Pyctuator(...)  # arguments removed for brevity\n\npyctuator.set_build_info(\n    name=\"app\",\n    version=\"1.3.1\",\n    time=datetime.fromisoformat(\"2019-12-21T10:09:54.876091\"),\n)\n\npyctuator.set_git_info(\n    commit=\"7d4fef3\",\n    time=datetime.fromisoformat(\"2019-12-24T14:18:32.123432\"),\n    branch=\"origin/master\",\n)\n```\n\nOnce you configure build and git info, you should see them in the Details tab of Spring Boot Admin:\n\n![Detailed Build Info](examples/images/Main_Details_BuildInfo.png)\n\n### Additional Application Info\nIn addition to adding build and git info, Pyctuator allows adding arbitrary application details to the \"Info\" section in SBA.\n\nThis is done by initializing the `additional_app_info` parameter with an arbitrary dictionary.\nFor example, you can provide links to your application's metrics:\n```python\nPyctuator(\n  app,\n  \"Flask Pyctuator\",\n  app_url=f\"http://172.18.0.1:5000\",\n  pyctuator_endpoint_url=f\"http://172.18.0.1:5000/pyctuator\",\n  registration_url=f\"http://localhost:8080/instances\",\n  app_description=\"Demonstrate Spring Boot Admin Integration with Flask\",\n  additional_app_info=dict(\n    serviceLinks=dict(\n      metrics=\"http://xyz/service/metrics\"\n    ),\n    podLinks=dict(\n      metrics=[\"http://xyz/pod/metrics/memory\", \"http://xyz/pod/metrics/cpu\"]\n    )\n  )\n)\n```\n\nThis will result with the following Info page in SBA:\n![img.png](examples/images/Additional_App_Info.png)\n\n### DB Health\nFor services that use SQL database via SQLAlchemy, Pyctuator can easily monitor and expose the connection's health \nusing the DbHealthProvider class as demonstrated below:\n\n```python\nengine = create_engine(\"mysql+pymysql://root:root@localhost:3306\")\npyctuator = Pyctuator(...)  # arguments removed for brevity\npyctuator.register_health_provider(DbHealthProvider(engine))\n```\n\nOnce you configure the health provider, you should see DB health info in the Details tab of Spring Boot Admin:\n\n![DB Health](examples/images/Main_DB_Health.png)\n\n### Redis health\nIf your service is using Redis, Pyctuator can monitor the connection to Redis by simply initializing a `RedisHealthProvider`:\n\n```python\nr = redis.Redis()\npyctuator = Pyctuator(...)  # arguments removed for brevity\npyctuator.register_health_provider(RedisHealthProvider(r))\n```\n\n### Custom Environment\nOut of the box, Pyctuator exposes Python's environment variables to Spring Boot Admin.\n\nIn addition, an application may register an environment provider to provide additional configuration that should be exposed via Spring Boot Admin. \n\nWhen the environment provider is called it should return a dictionary describing the environment. The returned dictionary is exposed to Spring Boot Admin.\n\nSince Spring Boot Admin doesn't support hierarchical environment (only a flat key/value mapping), the provided environment is flattened as dot-delimited keys.\n\nPyctuator tries to hide secrets from being exposed to Spring Boot Admin by replacing the values of \"suspicious\" keys with ***.\n\nSuspicious keys are keys that contain the words \"secret\", \"password\" and some forms of \"key\".\n\nFor example, if an application's configuration looks like this:\n\n```python\nconfig = {\n    \"a\": \"s1\",\n    \"b\": {\n        \"secret\": \"ha ha\",\n        \"c\": 625,\n    },\n    \"d\": {\n        \"e\": True,\n        \"f\": \"hello\",\n        \"g\": {\n            \"h\": 123,\n            \"i\": \"abcde\"\n        }\n    }\n}\n```\n\nAn environment provider can be registered like so:\n\n```python\npyctuator.register_environment_provider(\"config\", lambda: config)\n```\n\n### Filesystem and Memory Metrics\nPyctuator can provide filesystem and memory metrics.\n\nTo enable these metrics, install [psutil](https://github.com/giampaolo/psutil)\n\nNote that the `psutil` dependency is **optional** and is only required if you want to enable filesystem and memory monitoring.\n\n### Loggers\nPyctuator leverages Python's builtin `logging` framework and allows controlling log levels at runtime.\n \nNote that in order to control uvicorn's log level, you need to provide a logger object when instantiating it. For example:\n```python\nmyFastAPIServer = Server(\n    config=Config(\n        logger=logging.getLogger(\"uvi\"), \n        app=app, \n        loop=\"asyncio\"\n    )\n)\n```\n\n### Spring Boot Admin Using Basic Authentication\nPyctuator supports registration with Spring Boot Admin that requires basic authentications. The credentials are provided when initializing the Pyctuator instance as follows:\n```python\n# NOTE: Never include secrets in your code !!!\nauth = BasicAuth(os.getenv(\"sba-username\"), os.getenv(\"sba-password\"))\n\nPyctuator(\n    app,\n    \"Flask Pyctuator\",\n    app_url=\"http://localhost:5000\",\n    pyctuator_endpoint_url=f\"http://localhost:5000/pyctuator\",\n    registration_url=f\"http://spring-boot-admin:8080/instances\",\n    registration_auth=auth,\n)\n``` \n\n### Protecting Pyctuator with authentication\nSince there are numerous standard approaches to protect an API, Pyctuator doesn't explicitly support any of them. Instead, Pyctuator allows to customize its integration with the web-framework.\nSee the example in [fastapi_with_authentication_example_app.py](examples/FastAPI/fastapi_with_authentication_example_app.py).\n\n## Full blown examples\nThe `examples` folder contains full blown Python projects that are built using [Poetry](https://python-poetry.org/).\n\nTo run these examples, you'll need to have Spring Boot Admin running in a local docker container. A Spring Boot Admin Docker image is available [here](https://hub.docker.com/r/michayaak/spring-boot-admin).\n\nUnless the example includes a docker-compose file, you'll need to start Spring Boot Admin using docker directly:\n```sh\ndocker run --rm -p 8080:8080 --add-host=host.docker.internal:host-gateway michayaak/spring-boot-admin:2.2.3-1\n```\n(the docker image's tag represents the version of Spring Boot Admin, so if you need to use version `2.0.0`, use `michayaak/spring-boot-admin:2.0.0` instead, note it accepts connections on port 8082).\n\nThe examples include\n* [FastAPI Example](examples/FastAPI/README.md) - demonstrates integrating Pyctuator with the FastAPI web framework.\n* [Flask Example](examples/Flask/README.md) - demonstrates integrating Pyctuator with the Flask web framework.\n* [Advanced Example](examples/Advanced/README.md) - demonstrates configuring and using all the advanced features of Pyctuator.\n\n## Contributing\nTo set up a development environment, make sure you have Python 3.9 or newer installed, and run `make bootstrap`.\n\nUse `make check` to run static analysis tools.\n\nUse `make test` to run tests.\n"
  },
  {
    "path": "examples/Advanced/README.md",
    "content": "# Advanced Example\nThis example demonstrates using the optional features and customizations Pyctuator is offering.\n\n## Running the example\nBefore running this example, you'll need SBA (Spring Boot Admin), MySQL and Redis running on the same machine the example application will be running. \n\nIt is recommended to start these services using the `docker-compose.yml` part of this example, from the `examples/Advanced` directory perform:\n```shell\ndocker-compose --project-name example --file docker-compose.yml up --detach --force-recreate\n```  \n\nNext, from the `examples/Advanced` directory, run the example application using poetry as follows:\n```shell\npoetry install\npoetry run python advanced_example_app.py\n```\n\n# Using the example\nThe example application, available from http://localhost:8000 exposes example APIs for accessing the DB and Redis:\n* http://localhost/db/version - returns the DB's version\n* http://localhost:8000/redis/a-key - returns the value of the `a-key` key in redis\n\nConnect to Spring Boot Admin using http://localhost:8082.\n \n## Insights Details\n![Insights Details](../images/Advanced_Insights_Details.png)\n1. Monitor disk space (requires [psutil](https://pypi.org/project/psutil/)):\n2. Monitor connection to the DB (requies [sqlalchemy](https://pypi.org/project/SQLAlchemy/) and drivers specific to the DB being used)\n3. Monitor Redis client (requires [redis](https://pypi.org/project/redis/))\n4. Show build details\n5. Show Git details\n\n## Insights Metrics\nIf [psutil](https://pypi.org/project/psutil/) is installed, Pyctuator provides various process metrics in the \"Metrics\" tab:\n![Insights Metrics](../images/Advanced_Insights_Metrics.png)\n\n## Insights Environment\nPyctuator automatically exposes all environment variables, after scrubbing secrets, via the \"Environment\" tab under \"systemEnvironment\":\n![Insights Environment System Variables](../images/Advanced_Insights_Environment_systemEnvironment.png)\n\nAdditionally, Pyctuator can be configured to expose application-specific configuration via SBA (after scrubbing commonly identified secrets):\n![Insights Environment App Config](../images/Advanced_Insights_Environment_conf.png)\nNote that SBA only support flattened configuration hierarchy, which is automatically handled by Pyctuator.\n\n# Secret scrubbing\nPyctuator is using a \"secret scrubber\" for scrubbing/masking secrets from environment-variables and config-entries that are being reported to SBA.\nThe default secret scrubber is taking care fore masking values of keys that are expected to keep secrets.\nAdditionally, the default scrubber is masking credentials that are included in URLs.\n\nIt is possible to override the default scrubber by calling `set_secret_scrubber` providing it a mapping function that will hide/mask the desired keys. \nNote that the pattern used by the built in `SecretScrubber` can be replaced.\n\nFor example:\n\n```python\npyctuator = Pyctuator(...)  # arguments removed for brevity\nsecret_scrubber = SecretScrubber(keys_to_scrub=re.compile(\"^ABC$|^xyz$\", re.IGNORECASE)).scrub_secrets\npyctuator.set_secret_scrubber(secret_scrubber)\n```\n\n# Further customization\nUsing Pyctuator, it is possible to have SBA monitor application-specific health aspects using custom health-providers. \n\nHealth status may include multiple checks and may also include details on failures or the apps health.\n\nTo demonstrate this, the example application exposes additional API for setting the health, http://localhost:8000/health - posting a JSON formatted `pyctuator.health_provider.HealthDetails` to set the current health status.\n![Insights Details custom_health_up](../images/Advanced_Insights_Details_custom_health_up.png)\n\nFor example, the call bellow will make the application report its down.\n```shell\ncurl -X POST localhost:8000/health -d '{\"status\": \"DOWN\", \"details\": {\"backend_connectivity\": \"Down\", \"available_resources\": 41}}'\n```\n![Insights Details custom_health_down](../images/Advanced_Insights_Details_custom_health_down.png)\n"
  },
  {
    "path": "examples/Advanced/advanced_example_app.py",
    "content": "import datetime\nimport logging\nimport random\nfrom dataclasses import dataclass\nfrom typing import Any, Dict, List\nfrom starlette.requests import Request\n\nimport redis\nfrom fastapi import FastAPI\nfrom sqlalchemy.engine import Engine, create_engine\nfrom uvicorn import Server\nfrom uvicorn.config import Config\n\nfrom pyctuator.health.db_health_provider import DbHealthProvider\nfrom pyctuator.health.health_provider import HealthProvider, HealthStatus, Status, HealthDetails\nfrom pyctuator.health.redis_health_provider import RedisHealthProvider\nfrom pyctuator.pyctuator import Pyctuator\n\n# ----------------------------------------------------------------------------------------------------------------------\n# The `app_config` variable below holds all the settings of the application which in addition for being used by the\n# application, is also exposed to SBA (after scrubbing secrets) through Pyctuator.\n# ----------------------------------------------------------------------------------------------------------------------\n\napp_config = {\n    \"app\": {\n        \"name\": \"Advanced Example Server\",\n        \"version\": \"1.3.1\",\n        \"build_time\": datetime.datetime.fromisoformat(\"2019-12-21T10:09:54.876091\"),\n        \"description\": \"Demonstrate Spring Boot Admin Integration with FastAPI\",\n\n        \"git\": {\n            \"commit\": \"7d4fef3\",\n            \"time\": datetime.datetime.fromisoformat(\"2019-12-24T14:18:32.123432\"),\n            \"branch\": \"master\",\n        },\n\n        # the URL to use when accessing the application\n        \"public_endpoint\": f\"http://host.docker.internal:8000\",\n    },\n    \"mysql\": {\n        \"host\": \"localhost:3306\",\n        \"user\": \"root\",\n\n        # NOTE: don't put secrets in code, get them from env! (although Pyctuator will scrub this)\n        \"password\": \"root\",\n    },\n    \"monitoring\": {\n        # Because SBA runs in a container, this is the URL of the app/pyctuator as seen from the SBA container\n        \"pyctuator_endpoint\": f\"http://host.docker.internal:8000/pyctuator\",\n\n        # Spring Boot Admin registration URL\n        \"sba_registration_endpoint\": f\"http://localhost:8080/instances\",\n    }\n}\n\n\ndef get_conf(key: str) -> Any:\n    def recursive_get(child_conf: Dict, key_parts: List[str]) -> Any:\n        if len(key_parts) == 1:\n            return child_conf[key_parts[0]]\n        return recursive_get(child_conf[key_parts[0]], key_parts[1:])\n\n    return recursive_get(app_config, key.split(\".\"))\n\n\n# ----------------------------------------------------------------------------------------------------------------------\n# A FastAPI application is initialized providing some test API\n# ----------------------------------------------------------------------------------------------------------------------\n\nlogger = logging.getLogger(\"ExampleApp\")\n\n# Initialize a connection to the DB which the app is using\ndb_engine: Engine = create_engine(\n    \"mysql+pymysql://{user}:{password}@{host}\".format(\n        user=get_conf(\"mysql.user\"),\n        password=get_conf(\"mysql.password\"),\n        host=get_conf(\"mysql.host\"),\n    ),\n    echo=True)\n\n# Initialize a redis client for the app to use\nredis_client = redis.Redis()\n\napp = FastAPI(\n    title=get_conf(\"app.name\"),\n    description=get_conf(\"app.description\"),\n    docs_url=\"/api\",\n)\n\n\n@dataclass\nclass AppSpecificHealthDetails(HealthDetails):\n    backend_connectivity: str\n    available_resources: int\n\n\napp_specific_health = HealthStatus(\n    status=Status.UP,\n    details=AppSpecificHealthDetails(backend_connectivity=\"Connected\", available_resources=35)\n)\n\n\n@app.get(\"/\")\ndef hello():\n    logger.debug(f\"{datetime.datetime.now()} - {str(random.randint(0, 100))}\")\n    print(\"Printing to STDOUT\")\n    return \"Hello World!\"\n\n\n@app.get(\"/redis/{key}\")\ndef redis_get(key: str) -> Any:\n    return redis_client.get(key)\n\n\n@app.get(\"/db/version\")\ndef db_version() -> Any:\n    return db_engine.execute(\"SELECT version()\").next()[0]\n\n\n@app.post(\"/health\")\ndef health_up(request: Request, health: Dict) -> None:  # health should be of type HealthStatus\n    global app_specific_health\n    app_specific_health = HealthStatus(Status[health[\"status\"]], details=health[\"details\"])\n\n\n# ----------------------------------------------------------------------------------------------------------------------\n# Initialize Pyctuator with the SBA endpoint and all the extensions\n# ----------------------------------------------------------------------------------------------------------------------\n\npyctuator = Pyctuator(\n    app,\n    get_conf(\"app.name\"),\n    get_conf(\"app.public_endpoint\"),\n    get_conf(\"monitoring.pyctuator_endpoint\"),\n    get_conf(\"monitoring.sba_registration_endpoint\"),\n    app_description=app.description,\n)\n\n# Provide app's build info\npyctuator.set_build_info(\n    name=get_conf(\"app.name\"),\n    version=get_conf(\"app.version\"),\n    time=get_conf(\"app.build_time\"),\n)\n\n# Provide git commit info\npyctuator.set_git_info(\n    commit=get_conf(\"app.git.commit\"),\n    time=get_conf(\"app.git.time\"),\n    branch=get_conf(\"app.git.branch\"),\n)\n\n# Expose app's config via the Pyctuator API for SBA to show the scrubbed version in the UI\npyctuator.register_environment_provider(\"conf\", lambda: app_config)\n\n# Add health check for the DB connection\npyctuator.register_health_provider(DbHealthProvider(db_engine))\n\n# Add health check for the Redis client\npyctuator.register_health_provider(RedisHealthProvider(redis_client))\n\n\n# Register a custom health provider that reflects the contents of `healthy` and `health_details` to SBA\nclass CustomHealthProvider(HealthProvider):\n\n    def is_supported(self) -> bool:\n        return True\n\n    def get_name(self) -> str:\n        return \"app-specific-health\"\n\n    def get_health(self) -> HealthStatus:\n        return app_specific_health\n\n\npyctuator.register_health_provider(CustomHealthProvider())\n\n# ----------------------------------------------------------------------------------------------------------------------\n# The server is started after Pyctuator is created to allow Pyctuator to fully integrate with FastAPI\n# ----------------------------------------------------------------------------------------------------------------------\nserver = Server(config=(Config(app=app, loop=\"asyncio\", host=\"0.0.0.0\")))\nserver.run()\n"
  },
  {
    "path": "examples/Advanced/docker-compose.yml",
    "content": "version: '3'\nservices:\n  mysql:\n    image: mysql:5.7.22\n    ports:\n      - 3306:3306\n    environment:\n      - MYSQL_ROOT_PASSWORD=root\n  redis:\n    image: redis:alpine\n    ports:\n      - 6379:6379\n  sba:\n    image: michayaak/spring-boot-admin:2.2.2\n    ports:\n      - 8080:8080"
  },
  {
    "path": "examples/Advanced/pyproject.toml",
    "content": "[tool.poetry]\nname = \"fastapi-pyctuator-example\"\nversion = \"1.0.0\"\ndescription = \"Example of using Pyctuator\"\nauthors = [\n    \"Luke Skywalker <Luke@starwars.com>\",\n]\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\npsutil = { version = \"^5.6\" }\nfastapi = { version = \"^0.65.2\" }\nuvicorn = { version = \"^0.11.7\" }\npyctuator = { version = \"^1.2.0\" }\nsqlalchemy = { version = \"^1.3\" }\nPyMySQL = { version = \"^0.9.3\" }\ncryptography = { version = \"^2.8\" }\nredis = { version = \"^3.3\" }\n\n[build-system]\nrequires = [\"poetry>=0.12\"]\nbuild-backend = \"poetry.masonry.api\"\n\n"
  },
  {
    "path": "examples/FastAPI/README.md",
    "content": "# FastAPI example\nThis example demonstrates the integration with the [FastAPI](https://fastapi.tiangolo.com/) web-framework.\n\n## Running the example\n1. Start an instance of SBA (Spring Boot Admin):\n    ```sh\n    docker run --rm -p 8080:8080 --add-host=host.docker.internal:host-gateway michayaak/spring-boot-admin:2.2.3-1\n    ```\n2. Once Spring Boot Admin is running, you can run the examples as follow:\n    ```sh\n    cd examples/FastAPI\n    poetry install\n    poetry run python -m fastapi_example_app\n    ``` \n\n![FastAPI Example](../images/FastAPI.png)\n\n## Running an example where pyctuator requires authentication\nIn order to protect the Pyctuator endpoint, a customizer is used to make the required configuration changes to the API router. \nIn addition, the credentials need to be included in the registration request sent to SBA in order for it it could authenticate when querying the pyctuator API."
  },
  {
    "path": "examples/FastAPI/fastapi_example_app.py",
    "content": "import datetime\nimport logging\nimport random\n\nfrom fastapi import FastAPI\nfrom uvicorn import Server\nfrom uvicorn.config import Config\n\nfrom pyctuator.pyctuator import Pyctuator\n\nmy_logger = logging.getLogger(\"example\")\n\napp = FastAPI(\n    title=\"FastAPI Example Server\",\n    description=\"Demonstrate Spring Boot Admin Integration with FastAPI\",\n    docs_url=\"/api\",\n)\n\n\n@app.get(\"/\")\ndef read_root():\n    my_logger.debug(f\"{datetime.datetime.now()} - {str(random.randint(0, 100))}\")\n    print(\"Printing to STDOUT\")\n    return \"Hello World!\"\n\n\nexample_app_address = \"host.docker.internal\"\nexample_sba_address = \"localhost\"\n\npyctuator = Pyctuator(\n    app,\n    \"Example FastAPI\",\n    app_url=f\"http://{example_app_address}:8000\",\n    pyctuator_endpoint_url=f\"http://{example_app_address}:8000/pyctuator\",\n    registration_url=f\"http://{example_sba_address}:8080/instances\",\n    app_description=app.description,\n)\n\nserver = Server(config=(Config(\n    app=app,\n    loop=\"asyncio\",\n    host=\"0.0.0.0\",\n    log_level=logging.WARNING,\n)))\nserver.run()\n"
  },
  {
    "path": "examples/FastAPI/fastapi_with_authentication_example_app.py",
    "content": "import datetime\nimport logging\nimport random\nimport secrets\n\nfrom fastapi import FastAPI, Depends, APIRouter, HTTPException\nfrom fastapi.security import HTTPBasicCredentials, HTTPBasic\nfrom starlette import status\nfrom uvicorn import Server\nfrom uvicorn.config import Config\n\nfrom pyctuator.pyctuator import Pyctuator\n\nmy_logger = logging.getLogger(\"example\")\n\n\nclass SimplisticBasicAuth:\n    def __init__(self, username: str, password: str):\n        \"\"\"\n        Initializes a simplistic basic-auth FastAPI dependency with hardcoded username and password -\n        don't do this at home!\n        \"\"\"\n        self.username = username\n        self.password = password\n\n    def __call__(self, credentials: HTTPBasicCredentials = Depends(HTTPBasic(realm=\"pyctuator\"))):\n        correct_username = secrets.compare_digest(credentials.username, self.username)\n        correct_password = secrets.compare_digest(credentials.password, self.password) if self.password else True\n\n        if not (correct_username and correct_password):\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"Incorrect username or password\",\n                headers={\"WWW-Authenticate\": \"Basic\"},\n            )\n\n\nusername = \"u1\"\npassword = \"p2\"\nsecurity = SimplisticBasicAuth(username, password)\n\n\napp = FastAPI(\n    title=\"FastAPI Example Server\",\n    description=\"Demonstrate Spring Boot Admin Integration with FastAPI\",\n    docs_url=\"/api\",\n)\n\n\ndef add_authentication_to_pyctuator(router: APIRouter) -> None:\n    router.dependencies = [Depends(security)]\n\n\n@app.get(\"/\")\ndef read_root(credentials: HTTPBasicCredentials = Depends(security)):\n    my_logger.debug(f\"{datetime.datetime.now()} - {str(random.randint(0, 100))}\")\n    return {\"username\": credentials.username, \"password\": credentials.password}\n\n\nexample_app_address = \"172.18.0.1\"\nexample_sba_address = \"localhost\"\n\npyctuator = Pyctuator(\n    app,\n    \"Example FastAPI\",\n    app_url=f\"http://{example_app_address}:8000\",\n    pyctuator_endpoint_url=f\"http://{example_app_address}:8000/pyctuator\",\n    registration_url=f\"http://{example_sba_address}:8080/instances\",\n    app_description=app.description,\n    customizer=add_authentication_to_pyctuator,  # Customize Pyctuator's API router to require authentication\n    metadata={\n        \"user.name\": username,  # Include the credentials in the registration request sent to SBA\n        \"user.password\": password,\n    }\n)\n\n# Keep the console clear - configure uvicorn (FastAPI's WSGI web app) not to log the detail of every incoming request\nuvicorn_logger = logging.getLogger(\"uvicorn\")\nuvicorn_logger.setLevel(logging.WARNING)\n\nserver = Server(config=(Config(\n    app=app,\n    loop=\"asyncio\",\n    host=\"0.0.0.0\",\n    logger=uvicorn_logger,\n)))\nserver.run()\n"
  },
  {
    "path": "examples/FastAPI/pyproject.toml",
    "content": "[tool.poetry]\nname = \"fastapi-pyctuator-example\"\nversion = \"1.0.0\"\ndescription = \"Example of using Pyctuator\"\nauthors = [\n    \"Luke Skywalker <Luke@starwars.com>\",\n]\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\npsutil = { version = \"^5.6\" }\nfastapi = { version = \"^0.82.0\" }\nuvicorn = { version = \"^0.18.2\" }\npyctuator = { version = \"^1.2.0\" }\n\n[build-system]\nrequires = [\"poetry>=0.12\"]\nbuild-backend = \"poetry.masonry.api\"\n\n"
  },
  {
    "path": "examples/Flask/README.md",
    "content": "# Flask example\nThis example demonstrates the integration with the [Flask](https://flask.palletsprojects.com/) web-framework.\n\n## Running the example\n1. Start an instance of SBA (Spring Boot Admin):\n    ```sh\n    docker run --rm -p 8080:8080 --add-host=host.docker.internal:host-gateway michayaak/spring-boot-admin:2.2.3-1\n    ```\n2. Once Spring Boot Admin is running, you can run the examples as follow:\n    ```sh\n    cd examples/Flask\n    poetry install\n    poetry run python -m flask_example_app\n    ``` \n\n![Flask Example](../images/Flask.png)\n\n## Notes\n* Note that when Flask debugging is enabled, Pyctuator and Flask are initialized twice because Flask reloads the script. This causes Pyctuator to register twice thus the `startup` time alternates between the time these instances started.\n    ```Python\n    app.run(port=5000, host=\"0.0.0.0\", debug=True)\n    ```\n"
  },
  {
    "path": "examples/Flask/flask_example_app.py",
    "content": "import datetime\nimport logging\nimport random\n\nfrom flask import Flask\n\nfrom pyctuator.pyctuator import Pyctuator\n\n# Keep the console clear - configure werkzeug (flask's WSGI web app) not to log the detail of every incoming request\nlogging.getLogger(\"werkzeug\").setLevel(logging.WARNING)\n\nmy_logger = logging.getLogger(\"example\")\n\napp = Flask(\"Flask Example Server\")\n\n\n@app.route(\"/\")\ndef hello():\n    my_logger.debug(f\"{datetime.datetime.now()} - {str(random.randint(0, 100))}\")\n    print(\"Printing to STDOUT\")\n    return \"Hello World!\"\n\n\nexample_app_address = \"host.docker.internal\"\nexample_sba_address = \"localhost\"\n\nPyctuator(\n    app,\n    \"Flask Pyctuator\",\n    app_url=f\"http://{example_app_address}:5000\",\n    pyctuator_endpoint_url=f\"http://{example_app_address}:5000/pyctuator\",\n    registration_url=f\"http://{example_sba_address}:8080/instances\",\n    app_description=\"Demonstrate Spring Boot Admin Integration with Flask\",\n)\n\napp.run(port=5000, host=\"0.0.0.0\")\n"
  },
  {
    "path": "examples/Flask/pyproject.toml",
    "content": "[tool.poetry]\nname = \"flask-pyctuator-example\"\nversion = \"1.0.0\"\ndescription = \"Example of using Pyctuator\"\nauthors = [\n    \"Luke Skywalker <Luke@starwars.com>\",\n]\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\npsutil = { version = \"^5.6\" }\nflask = { version = \"^2.2.2\" }\npyctuator = { version = \"^1.2.0\" }\n\n[build-system]\nrequires = [\"poetry>=0.12\"]\nbuild-backend = \"poetry.masonry.api\"\n\n"
  },
  {
    "path": "examples/__init__.py",
    "content": ""
  },
  {
    "path": "examples/aiohttp/README.md",
    "content": "# aiohttp example\nThis example demonstrates the integration with the [aiohttp](https://docs.aiohttp.org).\n\n## Running the example\n1. Start an instance of SBA (Spring Boot Admin):\n    ```sh\n    docker run --rm -p 8080:8080 --add-host=host.docker.internal:host-gateway michayaak/spring-boot-admin:2.2.3-1\n    ```\n2. Once Spring Boot Admin is running, you can run the examples as follow:\n    ```sh\n    cd examples/aiohttp\n    poetry install\n    poetry run python -m aiohttp_example_app\n    ``` \n\n![aiohttp Example](../images/aiohttp.png)\n\n"
  },
  {
    "path": "examples/aiohttp/aiohttp_example_app.py",
    "content": "import datetime\nimport logging\nimport random\n\nfrom aiohttp import web\n\nfrom pyctuator.pyctuator import Pyctuator\n\nmy_logger = logging.getLogger(\"example\")\napp = web.Application()\nroutes = web.RouteTableDef()\n\n\n@routes.get('/')\ndef home(request: web.Request) -> web.Response:\n    my_logger.debug(f\"{datetime.datetime.now()} - {str(random.randint(0, 100))}\")\n    print(\"Printing to STDOUT\")\n    return web.Response(text=\"Hello World!\")\n\n\nexample_app_address = \"host.docker.internal\"\nexample_sba_address = \"localhost\"\n\npyctuator = Pyctuator(\n    app,\n    \"Example aiohttp\",\n    app_url=f\"http://{example_app_address}:8888\",\n    pyctuator_endpoint_url=f\"http://{example_app_address}:8888/pyctuator\",\n    registration_url=f\"http://{example_sba_address}:8080/instances\",\n    app_description=\"Demonstrate Spring Boot Admin Integration with aiohttp\",\n)\n\napp.add_routes(routes)\nweb.run_app(app, port=8888)\n"
  },
  {
    "path": "examples/aiohttp/pyproject.toml",
    "content": "[tool.poetry]\nname = \"aiohttp-pyctuator-example\"\nversion = \"1.0.0\"\ndescription = \"Example of using Pyctuator\"\nauthors = [\n    \"Luke Skywalker <Luke@starwars.com>\",\n]\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\npsutil = { version = \"^5.6\" }\naiohttp = { version = \"^3.6.2\" }\npyctuator = { version = \"^1.2.0\" }\n\n[build-system]\nrequires = [\"poetry>=0.12\"]\nbuild-backend = \"poetry.masonry.api\"\n\n"
  },
  {
    "path": "examples/tornado/README.md",
    "content": "# Tornado example\nThis example demonstrates the integration with the [Tornado](https://www.tornadoweb.org/).\n\n## Running the example\n1. Start an instance of SBA (Spring Boot Admin):\n    ```sh\n    docker run --rm -p 8080:8080 --add-host=host.docker.internal:host-gateway michayaak/spring-boot-admin:2.2.3-1\n    ```\n2. Once Spring Boot Admin is running, you can run the examples as follow:\n    ```sh\n    cd examples/tornado\n    poetry install\n    poetry run python -m tornado_example_app\n    ``` \n\n![tornado Example](../images/tornado.png)\n\n"
  },
  {
    "path": "examples/tornado/pyproject.toml",
    "content": "[tool.poetry]\nname = \"tornado-pyctuator-example\"\nversion = \"1.0.0\"\ndescription = \"Example of using Pyctuator\"\nauthors = [\n    \"Desmond Stonie <aneasystone@gmail.com>\",\n]\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\npsutil = { version = \"^5.6\" }\ntornado = { version = \"^6.0.4\" }\npyctuator = { version = \"^1.2.0\" }\n\n[build-system]\nrequires = [\"poetry>=0.12\"]\nbuild-backend = \"poetry.masonry.api\"\n\n"
  },
  {
    "path": "examples/tornado/tornado_example_app.py",
    "content": "import datetime\r\nimport logging\r\nimport random\r\n\r\nfrom tornado import ioloop\r\nfrom tornado.httpserver import HTTPServer\r\nfrom tornado.web import Application, RequestHandler\r\n\r\nfrom pyctuator.pyctuator import Pyctuator\r\n\r\nmy_logger = logging.getLogger(\"example\")\r\n\r\n\r\nclass HomeHandler(RequestHandler):\r\n    def get(self):\r\n        my_logger.debug(f\"{datetime.datetime.now()} - {str(random.randint(0, 100))}\")\r\n        self.write(\"Hello World!\")\r\n\r\n\r\napp = Application(\r\n    [\r\n        (r\"/\", HomeHandler)\r\n    ],\r\n    debug=False\r\n)\r\n\r\nexample_app_address = \"host.docker.internal\"\r\nexample_sba_address = \"localhost\"\r\n\r\nPyctuator(\r\n    app,\r\n    \"Tornado Pyctuator\",\r\n    app_url=f\"http://{example_app_address}:5000\",\r\n    pyctuator_endpoint_url=f\"http://{example_app_address}:5000/pyctuator\",\r\n    registration_url=f\"http://{example_sba_address}:8080/instances\",\r\n    app_description=\"Demonstrate Spring Boot Admin Integration with Tornado\",\r\n)\r\n\r\nhttp_server = HTTPServer(app, decompress_request=True)\r\nhttp_server.listen(5000)\r\nioloop.IOLoop.current().start()\r\n"
  },
  {
    "path": "mypy.ini",
    "content": "[mypy]\ndisallow_untyped_defs = True\nwarn_return_any = True\n\n[mypy-pytest]\nignore_missing_imports = True\n\n[mypy-psutil]\nignore_missing_imports = True\n\n[mypy-starlette.*]\nignore_missing_imports = True\n\n[mypy-uvicorn.*]\nignore_missing_imports = True\n\n[mypy-_pytest.monkeypatch.*]\nignore_missing_imports = True\n"
  },
  {
    "path": "pyctuator/__init__.py",
    "content": "__version__ = '0.1.0'\n"
  },
  {
    "path": "pyctuator/auth.py",
    "content": "from dataclasses import dataclass\nfrom typing import Optional\n\n\n@dataclass\nclass Auth:\n    pass\n\n\n@dataclass\nclass BasicAuth(Auth):\n    username: str\n    password: Optional[str]\n"
  },
  {
    "path": "pyctuator/endpoints.py",
    "content": "from enum import Flag, auto\n\n\nclass Endpoints(Flag):\n    NONE = 0\n    ENV = auto()\n    INFO = auto()\n    HEALTH = auto()\n    METRICS = auto()\n    LOGGERS = auto()\n    THREAD_DUMP = auto()\n    LOGFILE = auto()\n    HTTP_TRACE = auto()\n"
  },
  {
    "path": "pyctuator/environment/__init__.py",
    "content": ""
  },
  {
    "path": "pyctuator/environment/custom_environment_provider.py",
    "content": "from typing import Callable, Dict\n\nfrom pyctuator.environment.environment_provider import PropertiesSource, EnvironmentProvider, PropertyValue\n\n\ndef _flatten(prefix: str, dict_to_flatten: Dict) -> Dict:\n    \"\"\"\n    Recursively flattens a dictionary that may contain literal values (numbers and strings) and other dictionaries.\n    For example, given the following dictionary: {\n        \"a\": 1,\n        \"b\": {\n            \"c\": 2\n            \"d\": {\n                \"e\": 3\n            }\n        }\n    }\n    The flattened dictionary will be: {\n        \"a\": 1,\n        \"b.c\": 2,\n        \"b.d.e\": 3\n    }\n\n    :param prefix: when descending to a sub-dictionary, the prefix represents the keys higher in the hierarchy\n    :param dict_to_flatten: a dictionary, or a sub-dictionary to be flattened\n    :return: a dictionary from a dot-separated key to a literal value\n    \"\"\"\n    res: Dict = {}\n    for key, value in dict_to_flatten.items():\n        key_with_prefix = f\"{prefix}{key}.\"\n        if isinstance(value, dict):\n            res = {**res, **_flatten(key_with_prefix, value)}\n        else:\n            res[key_with_prefix[:-1]] = value\n    return res\n\n\nclass CustomEnvironmentProvider(EnvironmentProvider):\n\n    def __init__(self, name: str, env_provider: Callable[[], Dict]) -> None:\n        self.name = name\n        self.env_provider = env_provider\n\n    def get_properties_source(self, secret_scrubber: Callable[[Dict], Dict]) -> PropertiesSource:\n        flattened_env = _flatten(\"\", self.env_provider())\n        scrubbed_env = secret_scrubber(flattened_env)\n        properties_dict = {key: PropertyValue(value) for (key, value) in scrubbed_env.items()}\n        return PropertiesSource(self.name, properties_dict)\n"
  },
  {
    "path": "pyctuator/environment/environment_provider.py",
    "content": "from abc import ABC, abstractmethod\nfrom dataclasses import dataclass\n\nfrom typing import Mapping, Optional, List, Any, Callable, Dict\n\n\n@dataclass\nclass PropertyValue:\n    value: Any\n    origin: Optional[str] = None\n\n\n@dataclass\nclass PropertiesSource:\n    name: str\n    properties: Mapping[str, PropertyValue]\n\n\n@dataclass\nclass EnvironmentData:\n    activeProfiles: List[str]\n    propertySources: List[PropertiesSource]\n\n\nclass EnvironmentProvider(ABC):\n\n    @abstractmethod\n    def get_properties_source(self, secret_scrubber: Callable[[Dict], Dict]) -> PropertiesSource:\n        pass\n"
  },
  {
    "path": "pyctuator/environment/os_env_variables_impl.py",
    "content": "import os\nfrom typing import Dict, Callable\n\nfrom pyctuator.environment.environment_provider import PropertiesSource, PropertyValue, EnvironmentProvider\n\n\nclass OsEnvironmentVariableProvider(EnvironmentProvider):\n\n    def get_properties_source(self, secret_scrubber: Callable[[Dict], Dict]) -> PropertiesSource:\n        scrubbed_env = secret_scrubber(os.environ)  # type: ignore\n        properties_dict = {key: PropertyValue(value) for (key, value) in scrubbed_env.items()}\n        return PropertiesSource(\"systemEnvironment\", properties_dict)\n"
  },
  {
    "path": "pyctuator/environment/scrubber.py",
    "content": "import re\nfrom typing import Any, Dict, Pattern\n\ndefault_keys_to_scrub = re.compile(\"^(.*[^A-Za-z])?key([^A-Za-z].*)?$|.*secret.*|.*password.*|.*token.*\", re.IGNORECASE)\n\n\nclass SecretScrubber:\n\n    def __init__(self, keys_to_scrub: Pattern[str] = default_keys_to_scrub) -> None:\n        self.keys_to_scrub = keys_to_scrub\n        self.url_keys_to_scrub = re.compile(\".*url.*\", re.IGNORECASE)\n\n    def scrub_secrets(self, mapping: Dict) -> Dict:\n        \"\"\"Scrubs secrets from a dictionary replacing them with stars\n\n        :param mapping: a mapping with \"primitive\" values that may include secrets\n        :return: a copy of the input mapping having all secrets replaced with stars\n        \"\"\"\n\n        def scrub(key: Any, value: Any) -> Any:\n            if self.keys_to_scrub.match(key):\n                return \"******\"\n\n            if self.url_keys_to_scrub.match(key):\n                return re.sub(r\"(.*//[^:]*:).*(@.*)\", r\"\\1******\\2\", str(value))\n\n            return value\n\n        return {k: scrub(k, v) for (k, v) in mapping.items()}\n"
  },
  {
    "path": "pyctuator/health/__init__.py",
    "content": ""
  },
  {
    "path": "pyctuator/health/composite_health_provider.py",
    "content": "from dataclasses import dataclass\nfrom typing import Mapping\n\nfrom pyctuator.health.health_provider import HealthProvider, HealthStatus, Status\n\n\n@dataclass\nclass CompositeHealthStatus(HealthStatus):\n    status: Status\n    details: Mapping[str, HealthStatus]  # type: ignore[assignment]\n\n\nclass CompositeHealthProvider(HealthProvider):\n\n    def __init__(self, name: str, *health_providers: HealthProvider) -> None:\n        super().__init__()\n        self.name = name\n        self.health_providers = health_providers\n\n    def is_supported(self) -> bool:\n        return True\n\n    def get_name(self) -> str:\n        return self.name\n\n    def get_health(self) -> CompositeHealthStatus:\n        health_statuses: Mapping[str, HealthStatus] = {\n            provider.get_name(): provider.get_health()\n            for provider in self.health_providers\n            if provider.is_supported()\n        }\n\n        # Health is UP if no provider is registered\n        if not health_statuses:\n            return CompositeHealthStatus(Status.UP, health_statuses)\n\n        # If there's at least one provider and any of the providers is DOWN, the service is DOWN\n        service_is_down = any(health_status.status == Status.DOWN for health_status in health_statuses.values())\n        if service_is_down:\n            return CompositeHealthStatus(Status.DOWN, health_statuses)\n\n        # If there's at least one provider and none of the providers is DOWN and at least one is UP, the service is UP\n        service_is_up = any(health_status.status == Status.UP for health_status in health_statuses.values())\n        if service_is_up:\n            return CompositeHealthStatus(Status.UP, health_statuses)\n\n        # else, all providers are unknown so the service is UNKNOWN\n        return CompositeHealthStatus(Status.UNKNOWN, health_statuses)\n"
  },
  {
    "path": "pyctuator/health/db_health_provider.py",
    "content": "import importlib.util\nfrom dataclasses import dataclass\nfrom typing import Optional\n\nfrom sqlalchemy.engine import Engine\n\nfrom pyctuator.health.health_provider import HealthProvider, HealthStatus, Status, HealthDetails\n\n\n@dataclass\nclass DbHealthDetails(HealthDetails):\n    engine: str\n    failure: Optional[str] = None\n\n\n@dataclass\nclass DbHealthStatus(HealthStatus):\n    status: Status\n    details: DbHealthDetails\n\n\nclass DbHealthProvider(HealthProvider):\n\n    def __init__(self, engine: Engine, name: str = \"db\") -> None:\n        super().__init__()\n        self.engine = engine\n        self.name = name\n\n    def is_supported(self) -> bool:\n        return importlib.util.find_spec(\"sqlalchemy\") is not None\n\n    def get_name(self) -> str:\n        return self.name\n\n    def get_health(self) -> DbHealthStatus:\n        try:\n            with self.engine.connect() as conn:\n                if self.engine.dialect.do_ping(conn.connection): # type: ignore[arg-type]\n                    return DbHealthStatus(\n                        status=Status.UP,\n                        details=DbHealthDetails(self.engine.name)\n                    )\n\n            return DbHealthStatus(\n                status=Status.UNKNOWN,\n                details=DbHealthDetails(self.engine.name, \"Pinging failed\"))\n\n        except Exception as e:  # pylint: disable=broad-except\n            return DbHealthStatus(status=Status.DOWN, details=DbHealthDetails(self.engine.name, str(e)))\n"
  },
  {
    "path": "pyctuator/health/diskspace_health_impl.py",
    "content": "# pylint: disable=import-outside-toplevel\nimport importlib.util\nfrom dataclasses import dataclass\n\nfrom pyctuator.health.health_provider import HealthProvider, HealthDetails, HealthStatus, Status\n\n\n@dataclass\nclass DiskSpaceHealthDetails(HealthDetails):\n    total: int\n    free: int\n    threshold: int\n\n\n@dataclass\nclass DiskSpaceHealth(HealthStatus):\n    status: Status\n    details: DiskSpaceHealthDetails\n\n\nclass DiskSpaceHealthProvider(HealthProvider):\n\n    def __init__(self, free_bytes_down_threshold: int) -> None:\n        self.free_bytes_down_threshold = free_bytes_down_threshold\n\n        if importlib.util.find_spec(\"psutil\"):\n            # psutil is optional and must only be imported if it is installed\n            import psutil\n            self.psutil = psutil\n        else:\n            self.psutil = None\n\n    def is_supported(self) -> bool:\n        return self.psutil is not None\n\n    def get_name(self) -> str:\n        return \"diskSpace\"\n\n    def get_health(self) -> DiskSpaceHealth:\n        usage = self.psutil.disk_usage(\".\")\n        return DiskSpaceHealth(\n            Status.UP if usage.free > self.free_bytes_down_threshold else Status.DOWN,\n            DiskSpaceHealthDetails(usage.total, usage.free, self.free_bytes_down_threshold)\n        )\n"
  },
  {
    "path": "pyctuator/health/health_provider.py",
    "content": "import abc\nfrom abc import ABC\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom http import HTTPStatus\n\nfrom typing import Mapping\n\n\nclass Status(str, Enum):\n    UP = \"UP\"\n    DOWN = \"DOWN\"\n    UNKNOWN = \"UNKNOWN\"\n\n\n@dataclass\nclass HealthDetails:\n    pass\n\n\n@dataclass\nclass HealthStatus:\n    status: Status\n    details: HealthDetails\n\n\n@dataclass\nclass HealthSummary:\n    status: Status\n    details: Mapping[str, HealthStatus]\n\n    def http_status(self) -> int:\n        \"\"\"\n        :return: The HTTP according to the service's health. Done according to the documentation in\n                 https://docs.spring.io/spring-boot/docs/2.7.0/reference/htmlsingle/#actuator.endpoints.health.writing-custom-health-indicators\n                 The HTTP status code in the response reflects the overall health status. By default, OUT_OF_SERVICE\n                 and DOWN map to 503. Any unmapped health statuses, including UP, map to 200.\n        \"\"\"\n        if self.status == Status.DOWN:\n            return HTTPStatus.SERVICE_UNAVAILABLE\n        return HTTPStatus.OK\n\n\nclass HealthProvider(ABC):\n    @abc.abstractmethod\n    def is_supported(self) -> bool:\n        pass\n\n    @abc.abstractmethod\n    def get_name(self) -> str:\n        pass\n\n    @abc.abstractmethod\n    def get_health(self) -> HealthStatus:\n        pass\n"
  },
  {
    "path": "pyctuator/health/redis_health_provider.py",
    "content": "import importlib.util\nfrom dataclasses import dataclass\nfrom typing import Optional\n\nfrom redis import Redis\n\nfrom pyctuator.health.health_provider import HealthProvider, HealthStatus, Status, HealthDetails\n\n\n@dataclass\nclass RedisHealthDetails(HealthDetails):\n    version: Optional[str] = None\n    mode: Optional[str] = None\n    failure: Optional[str] = None\n\n\n@dataclass\nclass RedisHealthStatus(HealthStatus):\n    status: Status\n    details: RedisHealthDetails\n\n\nclass RedisHealthProvider(HealthProvider):\n\n    def __init__(self, redis: Redis, name: str = \"redis\") -> None:\n        super().__init__()\n        self.redis = redis\n        self.name = name\n\n    def is_supported(self) -> bool:\n        return importlib.util.find_spec(\"redis\") is not None\n\n    def get_name(self) -> str:\n        return self.name\n\n    def get_health(self) -> RedisHealthStatus:\n        try:\n            info = self.redis.info()\n\n            return RedisHealthStatus(\n                status=Status.UP,\n                details=RedisHealthDetails(\n                    version=info[\"redis_version\"],\n                    mode=info[\"redis_mode\"],\n                ))\n        except Exception as e:  # pylint: disable=broad-except\n            return RedisHealthStatus(\n                status=Status.DOWN,\n                details=RedisHealthDetails(\n                    failure=str(e)\n                ))\n"
  },
  {
    "path": "pyctuator/httptrace/__init__.py",
    "content": "from dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import List, Mapping, Optional\n\n\n@dataclass\nclass TraceResponse:\n    status: int\n    headers: Mapping[str, List[str]]\n\n\n@dataclass\nclass TraceRequest:\n    method: str\n    uri: str\n    headers: Mapping[str, List[str]]\n\n\n@dataclass\nclass Session:\n    id: str\n\n\n@dataclass\nclass Principal:\n    name: str\n\n\n@dataclass\nclass TraceRecord:\n    timestamp: datetime\n    principal: Optional[Principal]\n    session: Optional[Session]\n    request: TraceRequest\n    response: TraceResponse\n    timeTaken: int\n\n\n@dataclass\nclass Traces:\n    traces: List[TraceRecord]\n"
  },
  {
    "path": "pyctuator/httptrace/http_header_scrubber.py",
    "content": "import re\n\n_keys_to_scrub = re.compile(\n    \"^(.*[^A-Za-z])?key([^A-Za-z].*)?$|\"\n    \".*secret.*|\"\n    \".*password.*|\"\n    \".*token.*|\"\n    \".*authorization.*|\"\n    \".*authentication.*|\"\n    \".*cookie.*\",\n    re.IGNORECASE\n)\n\n\ndef scrub_header_value(key: str, value: str) -> str:\n    if _keys_to_scrub.match(key):\n        return \"******\"\n\n    return value\n"
  },
  {
    "path": "pyctuator/httptrace/http_tracer.py",
    "content": "import collections\nfrom typing import List, Mapping\nfrom pyctuator.httptrace.http_header_scrubber import scrub_header_value\n\nfrom pyctuator.httptrace import Traces, TraceRecord\n\n\nclass HttpTracer:\n    def __init__(self) -> None:\n        self.traces_list: collections.deque = collections.deque(maxlen=100)\n\n    def get_httptrace(self) -> Traces:\n        return Traces(list(self.traces_list))\n\n    def add_record(self, record: TraceRecord) -> None:\n\n        record.request.headers = self._scrub_and_normalize_headers(\n            record.request.headers)\n        record.response.headers = self._scrub_and_normalize_headers(\n            record.response.headers)\n\n        self.traces_list.append(record)\n\n    def _scrub_and_normalize_headers(self, headers: Mapping[str, List[str]]) -> Mapping[str, List[str]]:\n        return {header: [scrub_header_value(header, value) for value in values] for (header, values) in headers.items()}\n"
  },
  {
    "path": "pyctuator/impl/__init__.py",
    "content": "SBA_V2_CONTENT_TYPE = \"application/vnd.spring-boot.actuator.v2+json;charset=UTF-8\"\n"
  },
  {
    "path": "pyctuator/impl/aiohttp_pyctuator.py",
    "content": "import dataclasses\nimport json\nfrom collections import defaultdict\nfrom datetime import datetime\nfrom functools import partial\nfrom http import HTTPStatus\nfrom typing import Any, Callable, List, Mapping\n\nfrom aiohttp import web\nfrom multidict import CIMultiDictProxy\n\nfrom pyctuator.endpoints import Endpoints\nfrom pyctuator.httptrace import TraceRecord, TraceRequest, TraceResponse\nfrom pyctuator.impl import SBA_V2_CONTENT_TYPE\nfrom pyctuator.impl.pyctuator_impl import PyctuatorImpl\nfrom pyctuator.impl.pyctuator_router import PyctuatorRouter\n\n\n# pylint: disable=too-many-locals,unused-argument\nclass AioHttpPyctuator(PyctuatorRouter):\n    def __init__(self, app: web.Application, pyctuator_impl: PyctuatorImpl, disabled_endpoints: Endpoints) -> None:\n        super().__init__(app, pyctuator_impl)\n\n        custom_dumps = partial(\n            json.dumps, default=self._custom_json_serializer\n        )\n\n        async def empty_handler(request: web.Request) -> web.Response:\n            return web.Response(text='')\n\n        async def get_endpoints(request: web.Request) -> web.Response:\n            return web.json_response(self.get_endpoints_data(), dumps=custom_dumps)\n\n        async def get_environment(request: web.Request) -> web.Response:\n            return web.json_response(pyctuator_impl.get_environment(), dumps=custom_dumps)\n\n        async def get_info(request: web.Request) -> web.Response:\n            return web.json_response(pyctuator_impl.get_app_info(), dumps=custom_dumps)\n\n        async def get_health(request: web.Request) -> web.Response:\n            health = pyctuator_impl.get_health()\n            return web.json_response(health, status=health.http_status(), dumps=custom_dumps)\n\n        async def get_metric_names(request: web.Request) -> web.Response:\n            return web.json_response(pyctuator_impl.get_metric_names(), dumps=custom_dumps)\n\n        async def get_loggers(request: web.Request) -> web.Response:\n            return web.json_response(pyctuator_impl.logging.get_loggers(), dumps=custom_dumps)\n\n        async def set_logger_level(request: web.Request) -> web.Response:\n            request_dict = await request.json()\n            pyctuator_impl.logging.set_logger_level(\n                request.match_info[\"logger_name\"],\n                request_dict.get(\"configuredLevel\", None),\n            )\n            return web.json_response({})\n\n        async def get_logger(request: web.Request) -> web.Response:\n            logger_name = request.match_info[\"logger_name\"]\n            return web.json_response(pyctuator_impl.logging.get_logger(logger_name), dumps=custom_dumps)\n\n        async def get_thread_dump(request: web.Request) -> web.Response:\n            return web.json_response(pyctuator_impl.get_thread_dump(), dumps=custom_dumps)\n\n        async def get_httptrace(request: web.Request) -> web.Response:\n            raw_data = pyctuator_impl.http_tracer.get_httptrace()\n            return web.json_response(raw_data, dumps=custom_dumps)\n\n        async def get_metric_measurement(request: web.Request) -> web.Response:\n            return web.json_response(\n                pyctuator_impl.get_metric_measurement(request.match_info[\"metric_name\"]),\n                dumps=custom_dumps)\n\n        async def get_logfile(request: web.Request) -> web.Response:\n            range_header = request.headers.get(\"range\")\n            if not range_header:\n                return web.Response(\n                    body=f\"{pyctuator_impl.logfile.log_messages.get_range()}\"\n                )\n\n            str_res, start, end = pyctuator_impl.logfile.get_logfile(range_header)\n            response = web.Response(\n                status=HTTPStatus.PARTIAL_CONTENT.value,\n                body=str_res,\n                headers={\n                    \"Content-Type\": \"text/html; charset=UTF-8\",\n                    \"Accept-Ranges\": \"bytes\",\n                    \"Content-Range\": f\"bytes {start}-{end}/{end}\",\n                },\n            )\n            return response\n\n        @web.middleware\n        async def intercept_requests_and_responses(request: web.Request, handler: Callable) -> Any:\n            request_time = datetime.now()\n            response = await handler(request)\n            response_time = datetime.now()\n\n            # Set the SBA-V2 content type for responses from Pyctuator\n            if request.url.path.startswith(self.pyctuator_impl.pyctuator_endpoint_path_prefix):\n                response.headers[\"Content-Type\"] = SBA_V2_CONTENT_TYPE\n\n            # Record the request and response\n            new_record = self._create_record(\n                request, response, request_time, response_time\n            )\n            self.pyctuator_impl.http_tracer.add_record(record=new_record)\n            return response\n\n        routes = [\n            web.get(\"/pyctuator\", get_endpoints),\n        ]\n\n        if Endpoints.ENV not in disabled_endpoints:\n            routes.append(web.options(\"/pyctuator/env\", empty_handler))\n            routes.append(web.get(\"/pyctuator/env\", get_environment))\n\n        if Endpoints.INFO not in disabled_endpoints:\n            routes.append(web.options(\"/pyctuator/info\", empty_handler))\n            routes.append(web.get(\"/pyctuator/info\", get_info))\n\n        if Endpoints.HEALTH not in disabled_endpoints:\n            routes.append(web.options(\"/pyctuator/health\", empty_handler))\n            routes.append(web.get(\"/pyctuator/health\", get_health))\n\n        if Endpoints.METRICS not in disabled_endpoints:\n            routes.append(web.options(\"/pyctuator/metrics\", empty_handler))\n            routes.append(web.get(\"/pyctuator/metrics\", get_metric_names))\n            routes.append(web.get(\"/pyctuator/metrics/{metric_name}\", get_metric_measurement))\n\n        if Endpoints.LOGGERS not in disabled_endpoints:\n            routes.append(web.options(\"/pyctuator/loggers\", empty_handler))\n            routes.append(web.get(\"/pyctuator/loggers\", get_loggers))\n            routes.append(web.get(\"/pyctuator/loggers/{logger_name}\", get_logger))\n            routes.append(web.post(\"/pyctuator/loggers/{logger_name}\", set_logger_level))\n\n        if Endpoints.THREAD_DUMP not in disabled_endpoints:\n            routes.append(web.options(\"/pyctuator/dump\", empty_handler))\n            routes.append(web.options(\"/pyctuator/threaddump\", empty_handler))\n            routes.append(web.get(\"/pyctuator/dump\", get_thread_dump))\n            routes.append(web.get(\"/pyctuator/threaddump\", get_thread_dump))\n\n        if Endpoints.LOGFILE not in disabled_endpoints:\n            routes.append(web.options(\"/pyctuator/logfile\", empty_handler))\n            routes.append(web.get(\"/pyctuator/logfile\", get_logfile))\n\n        if Endpoints.HTTP_TRACE not in disabled_endpoints:\n            routes.append(web.options(\"/pyctuator/trace\", empty_handler))\n            routes.append(web.options(\"/pyctuator/httptrace\", empty_handler))\n            routes.append(web.get(\"/pyctuator/trace\", get_httptrace))\n            routes.append(web.get(\"/pyctuator/httptrace\", get_httptrace))\n\n        app.add_routes(routes)\n        app.middlewares.append(intercept_requests_and_responses)\n\n    def _custom_json_serializer(self, value: Any) -> Any:\n        if dataclasses.is_dataclass(value):\n            return dataclasses.asdict(value)\n\n        if isinstance(value, datetime):\n            return str(value)\n        return None\n\n    def _create_headers_dictionary(self, headers: CIMultiDictProxy[str]) -> Mapping[str, List[str]]:\n        headers_dict: Mapping[str, List[str]] = defaultdict(list)\n        for (key, value) in headers.items():\n            headers_dict[key].append(value)\n        return dict(headers_dict)\n\n    def _create_record(\n            self,\n            request: web.Request,\n            response: web.Response,\n            request_time: datetime,\n            response_time: datetime\n    ) -> TraceRecord:\n        new_record: TraceRecord = TraceRecord(\n            request_time,\n            None,\n            None,\n            TraceRequest(\n                request.method,\n                str(request.url),\n                self._create_headers_dictionary(request.headers),\n            ),\n            TraceResponse(\n                response.status,\n                self._create_headers_dictionary(CIMultiDictProxy(response.headers))\n            ),\n            int((response_time.timestamp() - request_time.timestamp()) * 1000),\n        )\n        return new_record\n"
  },
  {
    "path": "pyctuator/impl/fastapi_pyctuator.py",
    "content": "from collections import defaultdict\nfrom datetime import datetime\nfrom http import HTTPStatus\nfrom typing import Mapping, List, Callable\nfrom typing import Optional, Dict, Awaitable\n\nfrom fastapi import APIRouter, FastAPI, Header\nfrom pydantic import BaseModel\nfrom starlette.datastructures import Headers\nfrom starlette.requests import Request\nfrom starlette.responses import Response\n\nfrom pyctuator.endpoints import Endpoints\nfrom pyctuator.environment.environment_provider import EnvironmentData\nfrom pyctuator.httptrace import TraceRecord, TraceRequest, TraceResponse\nfrom pyctuator.httptrace.http_tracer import Traces\nfrom pyctuator.impl import SBA_V2_CONTENT_TYPE\nfrom pyctuator.impl.pyctuator_impl import PyctuatorImpl\nfrom pyctuator.impl.pyctuator_router import PyctuatorRouter\nfrom pyctuator.logging.pyctuator_logging import LoggersData, LoggerLevels\nfrom pyctuator.metrics.metrics_provider import Metric, MetricNames\nfrom pyctuator.threads.thread_dump_provider import ThreadDump\n\n\nclass FastApiLoggerItem(BaseModel):\n    configuredLevel: Optional[str]\n\n\n# pylint: disable=too-many-locals\nclass FastApiPyctuator(PyctuatorRouter):\n\n    # pylint: disable=unused-variable\n    def __init__(\n            self,\n            app: FastAPI,\n            pyctuator_impl: PyctuatorImpl,\n            include_in_openapi_schema: bool,\n            customizer: Optional[Callable[[APIRouter], None]],\n            disabled_endpoints: Endpoints,\n    ) -> None:\n        super().__init__(app, pyctuator_impl)\n        router = APIRouter()\n        if customizer:\n            customizer(router)\n\n        @router.get(\"/\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n        def get_endpoints() -> object:\n            return {\"_links\": self.get_endpoints_links()}\n\n        @router.options(\"/env\", include_in_schema=include_in_openapi_schema)\n        @router.options(\"/info\", include_in_schema=include_in_openapi_schema)\n        @router.options(\"/health\", include_in_schema=include_in_openapi_schema)\n        @router.options(\"/metrics\", include_in_schema=include_in_openapi_schema)\n        @router.options(\"/loggers\", include_in_schema=include_in_openapi_schema)\n        @router.options(\"/dump\", include_in_schema=include_in_openapi_schema)\n        @router.options(\"/threaddump\", include_in_schema=include_in_openapi_schema)\n        @router.options(\"/logfile\", include_in_schema=include_in_openapi_schema)\n        @router.options(\"/trace\", include_in_schema=include_in_openapi_schema)\n        @router.options(\"/httptrace\", include_in_schema=include_in_openapi_schema)\n        def options() -> None:\n            \"\"\"\n            Spring boot admin, after registration, issues multiple OPTIONS request to the monitored application in order\n            to determine the supported capabilities (endpoints).\n            Here we \"acknowledge\" that env, info and health are supported.\n            The \"include_in_schema=False\" is used to prevent from these OPTIONS endpoints to show up in the\n            documentation.\n            \"\"\"\n\n        if Endpoints.ENV not in disabled_endpoints:\n            @router.get(\"/env\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n            def get_environment() -> EnvironmentData:\n                return pyctuator_impl.get_environment()\n\n        if Endpoints.INFO not in disabled_endpoints:\n            @router.get(\"/info\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n            def get_info() -> Dict:\n                return pyctuator_impl.get_app_info()\n\n        if Endpoints.HEALTH not in disabled_endpoints:\n            @router.get(\"/health\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n            def get_health(response: Response) -> object:\n                health = pyctuator_impl.get_health()\n                response.status_code = health.http_status()\n                return health\n\n        if Endpoints.METRICS not in disabled_endpoints:\n            @router.get(\"/metrics\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n            def get_metric_names() -> MetricNames:\n                return pyctuator_impl.get_metric_names()\n\n            @router.get(\"/metrics/{metric_name}\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n            def get_metric_measurement(metric_name: str) -> Metric:\n                return pyctuator_impl.get_metric_measurement(metric_name)\n\n        # Retrieving All Loggers\n        if Endpoints.LOGGERS not in disabled_endpoints:\n            @router.get(\"/loggers\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n            def get_loggers() -> LoggersData:\n                return pyctuator_impl.logging.get_loggers()\n\n            @router.post(\"/loggers/{logger_name}\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n            def set_logger_level(item: FastApiLoggerItem, logger_name: str) -> Dict:\n                pyctuator_impl.logging.set_logger_level(logger_name, item.configuredLevel)\n                return {}\n\n            @router.get(\"/loggers/{logger_name}\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n            def get_logger(logger_name: str) -> LoggerLevels:\n                return pyctuator_impl.logging.get_logger(logger_name)\n\n        if Endpoints.THREAD_DUMP not in disabled_endpoints:\n            @router.get(\"/dump\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n            @router.get(\"/threaddump\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n            def get_thread_dump() -> ThreadDump:\n                return pyctuator_impl.get_thread_dump()\n\n        if Endpoints.LOGFILE not in disabled_endpoints:\n            @router.get(\"/logfile\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n            def get_logfile(range_header: str = Header(default=None,\n                                                       alias=\"range\")) -> Response:  # pylint: disable=redefined-builtin\n                if not range_header:\n                    return Response(content=pyctuator_impl.logfile.log_messages.get_range())\n\n                str_res, start, end = pyctuator_impl.logfile.get_logfile(range_header)\n\n                my_res = Response(\n                    status_code=HTTPStatus.PARTIAL_CONTENT.value,\n                    content=str_res,\n                    headers={\n                        \"Content-Type\": \"text/html; charset=UTF-8\",\n                        \"Accept-Ranges\": \"bytes\",\n                        \"Content-Range\": f\"bytes {start}-{end}/{end}\",\n                    })\n\n                return my_res\n\n        if Endpoints.HTTP_TRACE not in disabled_endpoints:\n            @router.get(\"/trace\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n            @router.get(\"/httptrace\", include_in_schema=include_in_openapi_schema, tags=[\"pyctuator\"])\n            def get_httptrace() -> Traces:\n                return pyctuator_impl.http_tracer.get_httptrace()\n\n        @app.middleware(\"http\")\n        async def intercept_requests_and_responses(\n                request: Request,\n                call_next: Callable[[Request], Awaitable[Response]]\n        ) -> Response:\n            request_time = datetime.now()\n            response: Response = await call_next(request)\n            response_time = datetime.now()\n\n            # Set the SBA-V2 content type for responses from Pyctuator\n            if request.url.path.startswith(self.pyctuator_impl.pyctuator_endpoint_path_prefix):\n                response.headers[\"Content-Type\"] = SBA_V2_CONTENT_TYPE\n\n            # Record the request and response\n            new_record = self._create_record(request, response, request_time, response_time)\n            self.pyctuator_impl.http_tracer.add_record(record=new_record)\n\n            return response\n\n        app.include_router(router, prefix=pyctuator_impl.pyctuator_endpoint_path_prefix)\n\n    def _create_headers_dictionary(self, headers: Headers) -> Mapping[str, List[str]]:\n        headers_dict: Mapping[str, List[str]] = defaultdict(list)\n        for (key, value) in headers.items():\n            headers_dict[key].append(value)\n        return headers_dict\n\n    def _create_record(\n            self,\n            request: Request,\n            response: Response,\n            request_time: datetime,\n            response_time: datetime,\n    ) -> TraceRecord:\n        new_record: TraceRecord = TraceRecord(\n            request_time,\n            None,\n            None,\n            TraceRequest(request.method, str(request.url), self._create_headers_dictionary(request.headers)),\n            TraceResponse(response.status_code, self._create_headers_dictionary(response.headers)),\n            int((response_time.timestamp() - request_time.timestamp()) * 1000),\n        )\n        return new_record\n"
  },
  {
    "path": "pyctuator/impl/flask_pyctuator.py",
    "content": "import json\nfrom collections import defaultdict\nfrom datetime import datetime, date\nfrom http import HTTPStatus\nfrom typing import Dict, Tuple, Any, Mapping, List\n\nfrom flask import Flask, Blueprint, request, jsonify, after_this_request\nfrom flask import Response, make_response\nfrom flask.json.provider import DefaultJSONProvider\n# from flask.json import JSONEncoder\nfrom werkzeug.datastructures import Headers\n\nfrom pyctuator.endpoints import Endpoints\nfrom pyctuator.httptrace import TraceRecord, TraceRequest, TraceResponse\nfrom pyctuator.impl import SBA_V2_CONTENT_TYPE\nfrom pyctuator.impl.pyctuator_impl import PyctuatorImpl\nfrom pyctuator.impl.pyctuator_router import PyctuatorRouter\n\n\nclass IsoTimeJSONProvider(DefaultJSONProvider):\n    \"\"\" Override Flask's JSON encoding of datetime to assure ISO format is used.\n\n    By default, when Flask is rendering a response to JSON, it is formatting datetime, date and time according to\n    RFC-822 which is different from the ISO format used by SBA.\n\n    As of 2.2.*, changing the datetime and date JSON encoding is done globally,\n    see https://stackoverflow.com/a/74618781/2692895 (which is an updated reply to\n    https://stackoverflow.com/questions/43663552/keep-a-datetime-date-in-yyyy-mm-dd-format-when-using-flasks-jsonify)\n    \"\"\"\n\n    def default(self, o: Any) -> Any:\n        if isinstance(o, (date, datetime)):\n            return o.isoformat()\n        return super().default(o)\n\n\nclass FlaskPyctuator(PyctuatorRouter):\n\n    # pylint: disable=too-many-locals, unused-variable\n    def __init__(\n            self,\n            app: Flask,\n            pyctuator_impl: PyctuatorImpl,\n            disabled_endpoints: Endpoints,\n    ) -> None:\n        super().__init__(app, pyctuator_impl)\n\n        path_prefix: str = pyctuator_impl.pyctuator_endpoint_path_prefix\n        flask_blueprint: Blueprint = Blueprint(\"flask_blueprint\", \"pyctuator\", )\n        app.json = IsoTimeJSONProvider(app)\n\n        @app.before_request\n        def intercept_requests_and_responses() -> None:\n            request_time = datetime.now()\n\n            @after_this_request\n            def after_response(response: Response) -> Response:\n                response_time = datetime.now()\n\n                # Set the SBA-V2 content type for responses from Pyctuator\n                if request.path.startswith(self.pyctuator_impl.pyctuator_endpoint_path_prefix):\n                    response.headers[\"Content-Type\"] = SBA_V2_CONTENT_TYPE\n\n                # Record the request and response\n                self.record_request_and_response(response, request_time, response_time)\n                return response\n\n        @flask_blueprint.route(\"/\")\n        def get_endpoints() -> Any:\n            return jsonify(self.get_endpoints_data())\n\n        if Endpoints.ENV not in disabled_endpoints:\n            @flask_blueprint.route(\"/env\")\n            def get_environment() -> Any:\n                return jsonify(pyctuator_impl.get_environment())\n\n        if Endpoints.INFO not in disabled_endpoints:\n            @flask_blueprint.route(\"/info\")\n            def get_info() -> Any:\n                return jsonify(pyctuator_impl.get_app_info())\n\n        if Endpoints.HEALTH not in disabled_endpoints:\n            @flask_blueprint.route(\"/health\")\n            def get_health() -> Any:\n                health = pyctuator_impl.get_health()\n                return jsonify(health), health.http_status()\n\n        if Endpoints.METRICS not in disabled_endpoints:\n            @flask_blueprint.route(\"/metrics\")\n            def get_metric_names() -> Any:\n                return jsonify(pyctuator_impl.get_metric_names())\n\n            @flask_blueprint.route(\"/metrics/<metric_name>\")\n            def get_metric_measurement(metric_name: str) -> Any:\n                return jsonify(pyctuator_impl.get_metric_measurement(metric_name))\n\n        # Retrieving All Loggers\n        if Endpoints.LOGGERS not in disabled_endpoints:\n            @flask_blueprint.route(\"/loggers\")\n            def get_loggers() -> Any:\n                return jsonify(pyctuator_impl.logging.get_loggers())\n\n            @flask_blueprint.route(\"/loggers/<logger_name>\", methods=['POST'])\n            def set_logger_level(logger_name: str) -> Dict:\n                request_dict = json.loads(request.data)\n                pyctuator_impl.logging.set_logger_level(logger_name, request_dict.get(\"configuredLevel\", None))\n                return {}\n\n            @flask_blueprint.route(\"/loggers/<logger_name>\")\n            def get_logger(logger_name: str) -> Any:\n                return jsonify(pyctuator_impl.logging.get_logger(logger_name))\n\n        if Endpoints.THREAD_DUMP not in disabled_endpoints:\n            @flask_blueprint.route(\"/threaddump\")\n            @flask_blueprint.route(\"/dump\")\n            def get_thread_dump() -> Any:\n                return jsonify(pyctuator_impl.get_thread_dump())\n\n        if Endpoints.LOGFILE not in disabled_endpoints:\n            @flask_blueprint.route(\"/logfile\")\n            def get_logfile() -> Tuple[Response, int]:\n                range_header = request.environ.get('HTTP_RANGE')\n                if not range_header:\n                    response: Response = make_response(pyctuator_impl.logfile.log_messages.get_range())\n                    return response, HTTPStatus.OK\n\n                str_res, start, end = pyctuator_impl.logfile.get_logfile(range_header)\n\n                resp: Response = make_response(str_res)\n                resp.headers[\"Content-Type\"] = \"text/html; charset=UTF-8\"\n                resp.headers[\"Accept-Ranges\"] = \"bytes\"\n                resp.headers[\"Content-Range\"] = f\"bytes {start}-{end}/{end}\"\n\n                return resp, HTTPStatus.PARTIAL_CONTENT\n\n        if Endpoints.HTTP_TRACE not in disabled_endpoints:\n            @flask_blueprint.route(\"/trace\")\n            @flask_blueprint.route(\"/httptrace\")\n            def get_httptrace() -> Any:\n                return jsonify(pyctuator_impl.http_tracer.get_httptrace())\n\n        app.register_blueprint(flask_blueprint, url_prefix=path_prefix)\n\n    def _create_headers_dictionary_flask(self, headers: Headers) -> Mapping[str, List[str]]:\n        headers_dict: Mapping[str, List[str]] = defaultdict(list)\n        for (key, value) in headers.items():\n            headers_dict[key].append(value)\n        return dict(headers_dict)\n\n    def record_request_and_response(\n            self,\n            response: Response,\n            request_time: datetime,\n            response_time: datetime,\n    ) -> None:\n        new_record = TraceRecord(\n            request_time,\n            None,\n            None,\n            TraceRequest(request.method, str(request.url), self._create_headers_dictionary_flask(request.headers)),\n            TraceResponse(response.status_code, self._create_headers_dictionary_flask(response.headers)),\n            int((response_time.timestamp() - request_time.timestamp()) * 1000),\n        )\n        self.pyctuator_impl.http_tracer.add_record(record=new_record)\n"
  },
  {
    "path": "pyctuator/impl/pyctuator_impl.py",
    "content": "import dataclasses\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import List, Dict, Mapping, Optional, Callable\nfrom urllib.parse import urlparse\n\nfrom pyctuator.endpoints import Endpoints\nfrom pyctuator.environment.environment_provider import EnvironmentData, EnvironmentProvider\nfrom pyctuator.environment.scrubber import SecretScrubber\nfrom pyctuator.health.health_provider import HealthStatus, HealthSummary, Status, HealthProvider\nfrom pyctuator.httptrace.http_tracer import HttpTracer\nfrom pyctuator.logfile.logfile import PyctuatorLogfile  # type: ignore\nfrom pyctuator.logging.pyctuator_logging import PyctuatorLogging\nfrom pyctuator.metrics.metrics_provider import Metric, MetricNames, MetricsProvider\nfrom pyctuator.threads.thread_dump_provider import ThreadDump, ThreadDumpProvider\n\n\n@dataclass\nclass GitCommitInfo:\n    time: datetime\n    id: str\n\n\n@dataclass\nclass GitInfo:\n    commit: GitCommitInfo\n    branch: Optional[str] = None\n\n\n@dataclass\nclass BuildInfo:\n    name: Optional[str] = None\n    artifact: Optional[str] = None\n    group: Optional[str] = None\n    version: Optional[str] = None\n    time: Optional[datetime] = None\n\n\n@dataclass\nclass AppDetails:\n    name: str\n    description: Optional[str] = None\n\n\n@dataclass\nclass AppInfo:\n    app: AppDetails\n    build: Optional[BuildInfo] = None\n    git: Optional[GitInfo] = None\n\n\nclass PyctuatorImpl:\n    # pylint: disable=too-many-instance-attributes\n    def __init__(\n            self,\n            app_info: AppInfo,\n            pyctuator_endpoint_url: str,\n            logfile_max_size: int,\n            logfile_formatter: str,\n            additional_app_info: Optional[dict],\n            disabled_endpoints: Endpoints\n    ):\n        self.app_info = app_info\n        self.pyctuator_endpoint_url = pyctuator_endpoint_url\n        self.additional_app_info = additional_app_info\n        self.disabled_endpoints = disabled_endpoints\n\n        self.metrics_providers: List[MetricsProvider] = []\n        self.health_providers: List[HealthProvider] = []\n        self.environment_providers: List[EnvironmentProvider] = []\n        self.logging = PyctuatorLogging()\n        self.thread_dump_provider = ThreadDumpProvider()\n        self.logfile = PyctuatorLogfile(max_size=logfile_max_size, formatter=logfile_formatter)\n        self.http_tracer = HttpTracer()\n\n        self.secret_scrubber: Callable[[Dict], Dict] = SecretScrubber().scrub_secrets\n\n        # Determine the endpoint's URL path prefix and make sure it doesn't end with a \"/\"\n        self.pyctuator_endpoint_path_prefix = urlparse(pyctuator_endpoint_url).path\n        if self.pyctuator_endpoint_path_prefix[-1:] == \"/\":\n            self.pyctuator_endpoint_path_prefix = self.pyctuator_endpoint_path_prefix[:-1]\n\n    def register_metrics_provider(self, provider: MetricsProvider) -> None:\n        self.metrics_providers.append(provider)\n\n    def register_health_providers(self, provider: HealthProvider) -> None:\n        self.health_providers.append(provider)\n\n    def register_environment_provider(self, provider: EnvironmentProvider) -> None:\n        self.environment_providers.append(provider)\n\n    def set_secret_scrubber(self, secret_scrubber: Callable[[Dict], Dict]) -> None:\n        self.secret_scrubber = secret_scrubber\n\n    def get_environment(self) -> EnvironmentData:\n        active_profiles: List[str] = []\n        env_data = EnvironmentData(\n            active_profiles,\n            [source.get_properties_source(self.secret_scrubber) for source in self.environment_providers]\n        )\n        return env_data\n\n    def set_git_info(self, git_info: GitInfo) -> None:\n        self.app_info.git = git_info\n\n    def set_build_info(self, build_info: BuildInfo) -> None:\n        self.app_info.build = build_info\n\n    def get_health(self) -> HealthSummary:\n        health_statuses: Mapping[str, HealthStatus] = {\n            provider.get_name(): provider.get_health()\n            for provider in self.health_providers\n            if provider.is_supported()\n        }\n\n        # Health is UP if no provider is registered\n        if not health_statuses:\n            return HealthSummary(Status.UP, health_statuses)\n\n        # If there's at least one provider and any of the providers is DOWN, the service is DOWN\n        service_is_down = any(health_status.status == Status.DOWN for health_status in health_statuses.values())\n        if service_is_down:\n            return HealthSummary(Status.DOWN, health_statuses)\n\n        # If there's at least one provider and none of the providers is DOWN and at least one is UP, the service is UP\n        service_is_up = any(health_status.status == Status.UP for health_status in health_statuses.values())\n        if service_is_up:\n            return HealthSummary(Status.UP, health_statuses)\n\n        # else, all providers are unknown so the service is UNKNOWN\n        return HealthSummary(Status.UNKNOWN, health_statuses)\n\n    def get_metric_names(self) -> MetricNames:\n        metric_names = []\n        for provider in self.metrics_providers:\n            for metric_name in provider.get_supported_metric_names():\n                metric_names.append(metric_name)\n        return MetricNames(metric_names)\n\n    def get_metric_measurement(self, metric_name: str) -> Metric:\n        for provider in self.metrics_providers:\n            if metric_name.startswith(provider.get_prefix()):\n                return provider.get_metric(metric_name)\n        raise KeyError(f\"Unknown metric {metric_name}\")\n\n    def get_thread_dump(self) -> ThreadDump:\n        return self.thread_dump_provider.get_thread_dump()\n\n    def get_app_info(self) -> Dict:\n        app_info_dict = {k: v for (k, v) in dataclasses.asdict(self.app_info).items() if v}\n\n        if self.additional_app_info:\n            app_info_dict = {**app_info_dict, **self.additional_app_info}\n\n        return app_info_dict\n"
  },
  {
    "path": "pyctuator/impl/pyctuator_router.py",
    "content": "from abc import ABC\nfrom dataclasses import dataclass\nfrom typing import Any, Optional, Mapping\n\nfrom pyctuator.endpoints import Endpoints\nfrom pyctuator.impl.pyctuator_impl import PyctuatorImpl\n\n\n@dataclass\nclass LinkHref:\n    href: str\n    templated: bool\n\n\n@dataclass\nclass EndpointsData:\n    _links: Mapping[str, LinkHref]\n\n\nclass PyctuatorRouter(ABC):\n\n    def __init__(\n            self,\n            app: Any,\n            pyctuator_impl: PyctuatorImpl,\n    ):\n        self.app = app\n        self.pyctuator_impl = pyctuator_impl\n\n    def get_endpoints_data(self) -> EndpointsData:\n        return EndpointsData(self.get_endpoints_links())\n\n    def get_endpoints_links(self) -> Mapping[str, LinkHref]:\n        def link_href(endpoint: Endpoints, path: str) -> Optional[LinkHref]:\n            return None if endpoint in self.pyctuator_impl.disabled_endpoints \\\n                else LinkHref(self.pyctuator_impl.pyctuator_endpoint_url + path, False)\n\n        endpoints = {\n            \"self\": LinkHref(self.pyctuator_impl.pyctuator_endpoint_url, False),\n            \"env\": link_href(Endpoints.ENV, \"/env\"),\n            \"info\": link_href(Endpoints.INFO, \"/info\"),\n            \"health\": link_href(Endpoints.HEALTH, \"/health\"),\n            \"metrics\": link_href(Endpoints.METRICS, \"/metrics\"),\n            \"loggers\": link_href(Endpoints.LOGGERS, \"/loggers\"),\n            \"dump\": link_href(Endpoints.THREAD_DUMP, \"/dump\"),\n            \"threaddump\": link_href(Endpoints.THREAD_DUMP, \"/threaddump\"),\n            \"logfile\": link_href(Endpoints.LOGFILE, \"/logfile\"),\n            \"httptrace\": link_href(Endpoints.HTTP_TRACE, \"/httptrace\"),\n        }\n\n        return {endpoint: link_href for (endpoint, link_href) in endpoints.items() if link_href is not None}\n"
  },
  {
    "path": "pyctuator/impl/spring_boot_admin_registration.py",
    "content": "import http.client\nimport json\nimport logging\nimport os\nimport ssl\nimport threading\nimport urllib.parse\nfrom base64 import b64encode\nfrom datetime import datetime\nfrom http.client import HTTPConnection, HTTPResponse\nfrom typing import Optional, Dict\n\nfrom pyctuator.auth import Auth, BasicAuth\n\n\n# pylint: disable=too-many-instance-attributes\nclass BootAdminRegistrationHandler:\n\n    def __init__(\n            self,\n            registration_url: str,\n            registration_auth: Optional[Auth],\n            application_name: str,\n            pyctuator_base_url: str,\n            start_time: datetime,\n            service_url: str,\n            registration_interval_sec: float,\n            application_metadata: Optional[dict] = None,\n            ssl_context: Optional[ssl.SSLContext] = None,\n    ) -> None:\n        self.registration_url = registration_url\n        self.registration_auth = registration_auth\n        self.application_name = application_name\n        self.pyctuator_base_url = pyctuator_base_url\n        self.start_time = start_time\n        self.service_url = service_url if service_url.endswith(\"/\") else service_url + \"/\"\n        self.registration_interval_sec = registration_interval_sec\n        self.instance_id = None\n        self.application_metadata = application_metadata if application_metadata else {}\n        self.ssl_context = ssl_context\n\n        self.should_continue_registration_schedule: bool = False\n        self.disable_certificate_validation_for_https_registration: bool = \\\n            os.getenv(\"PYCTUATOR_REGISTRATION_NO_CERT\") is not None\n\n    def _schedule_next_registration(self, registration_interval_sec: float) -> None:\n        timer = threading.Timer(\n            registration_interval_sec,\n            self._register_with_admin_server,\n            []\n        )\n        timer.setDaemon(True)\n        timer.start()\n\n    def _register_with_admin_server(self) -> None:\n        # When waking up, make sure registration is still needed\n        if not self.should_continue_registration_schedule:\n            return\n\n        registration_data = {\n            \"name\": self.application_name,\n            \"managementUrl\": self.pyctuator_base_url,\n            \"healthUrl\": f\"{self.pyctuator_base_url}/health\",\n            \"serviceUrl\": self.service_url,\n            \"metadata\": {\n                \"startup\": self.start_time.isoformat(),\n                **self.application_metadata\n            }\n        }\n\n        logging.debug(\"Trying to post registration data to %s: %s\", self.registration_url, registration_data)\n\n        conn: Optional[HTTPConnection] = None\n        try:\n            headers = {\"Content-type\": \"application/json\"}\n            self.authenticate(headers)\n\n            response = self._http_request(self.registration_url, \"POST\", headers, json.dumps(registration_data))\n\n            if response.status < 200 or response.status >= 300:\n                logging.warning(\"Failed registering with boot-admin, got %s - %s\", response.status, response.read())\n            else:\n                self.instance_id = json.loads(response.read().decode('utf-8'))[\"id\"]\n\n        except Exception as e:  # pylint: disable=broad-except\n            logging.warning(\"Failed registering with boot-admin, %s (%s)\", e, type(e))\n\n        finally:\n            if conn:\n                conn.close()\n\n        # Schedule the next registration unless asked to abort\n        if self.should_continue_registration_schedule:\n            self._schedule_next_registration(self.registration_interval_sec)\n\n    def deregister_from_admin_server(self) -> None:\n        if self.instance_id is None:\n            return\n\n        headers = {}\n        self.authenticate(headers)\n\n        deregistration_url = f\"{self.registration_url}/{self.instance_id}\"\n        logging.info(\"Deregistering from %s\", deregistration_url)\n\n        conn: Optional[HTTPConnection] = None\n        try:\n            response = self._http_request(deregistration_url, \"DELETE\", headers)\n\n            if response.status < 200 or response.status >= 300:\n                logging.warning(\"Failed deregistering from boot-admin, got %s - %s\", response.status, response.read())\n\n        except Exception as e:  # pylint: disable=broad-except\n            logging.warning(\"Failed deregistering from boot-admin, %s (%s)\", e, type(e))\n\n        finally:\n            if conn:\n                conn.close()\n\n    def authenticate(self, headers: Dict) -> None:\n        if isinstance(self.registration_auth, BasicAuth):\n            password = self.registration_auth.password if self.registration_auth.password else \"\"\n            authorization_string = self.registration_auth.username + \":\" + password\n            encoded_authorization: str = b64encode(bytes(authorization_string, \"utf-8\")).decode(\"ascii\")\n            headers[\"Authorization\"] = f\"Basic {encoded_authorization}\"\n\n    def start(self, initial_delay_sec: Optional[float] = None) -> None:\n        logging.info(\"Starting recurring registration of %s with %s\",\n                     self.pyctuator_base_url, self.registration_url)\n        self.should_continue_registration_schedule = True\n        self._schedule_next_registration(initial_delay_sec or self.registration_interval_sec)\n\n    def stop(self) -> None:\n        logging.info(\"Stopping recurring registration\")\n        self.should_continue_registration_schedule = False\n\n    def _http_request(self, url: str, method: str, headers: Dict[str, str], body: Optional[str] = None) -> HTTPResponse:\n        url_parts = urllib.parse.urlsplit(url)\n        if not url_parts.hostname:\n            raise ValueError(f\"Unknown host in {url}\")\n        hostname: str = url_parts.hostname\n\n        if url_parts.scheme == \"http\":\n            conn = http.client.HTTPConnection(host=hostname, port=url_parts.port)\n        elif url_parts.scheme == \"https\":\n            context = self.ssl_context\n            if not context and self.disable_certificate_validation_for_https_registration:\n                context = ssl.SSLContext()\n                context.verify_mode = ssl.CERT_NONE\n            conn = http.client.HTTPSConnection(url_parts.hostname, url_parts.port, context=context)\n        else:\n            raise ValueError(f\"Unknown scheme in {url}\")\n\n        conn.request(\n            method,\n            url_parts.path,\n            body=body,\n            headers=headers,\n        )\n        return conn.getresponse()\n"
  },
  {
    "path": "pyctuator/impl/tornado_pyctuator.py",
    "content": "import dataclasses\r\nimport json\r\nfrom datetime import datetime, timedelta\r\nfrom functools import partial\r\nfrom http import HTTPStatus\r\nfrom typing import Any, Optional, Callable, Mapping, List\r\n\r\nfrom tornado.httputil import HTTPHeaders\r\nfrom tornado.web import Application, RequestHandler\r\n\r\nfrom pyctuator.endpoints import Endpoints\r\nfrom pyctuator.httptrace import TraceRecord, TraceRequest, TraceResponse\r\nfrom pyctuator.impl import SBA_V2_CONTENT_TYPE\r\nfrom pyctuator.impl.pyctuator_impl import PyctuatorImpl\r\nfrom pyctuator.impl.pyctuator_router import PyctuatorRouter\r\n\r\n\r\n# pylint: disable=abstract-method\r\nclass AbstractPyctuatorHandler(RequestHandler):\r\n    pyctuator_router: Optional[PyctuatorRouter] = None\r\n    dumps: Optional[Callable[[Any], str]] = None\r\n\r\n    def initialize(self) -> None:\r\n        self.pyctuator_router = self.application.settings.get(\"pyctuator_router\")\r\n        self.dumps = self.application.settings.get(\"custom_dumps\")\r\n        self.set_header(\"Content-Type\", SBA_V2_CONTENT_TYPE)\r\n\r\n    def options(self) -> None:\r\n        assert self.pyctuator_router is not None\r\n        assert self.dumps is not None\r\n        self.write(\"\")\r\n\r\n\r\nclass PyctuatorHandler(AbstractPyctuatorHandler):\r\n    def get(self) -> None:\r\n        assert self.pyctuator_router is not None\r\n        assert self.dumps is not None\r\n        self.write(self.dumps(self.pyctuator_router.get_endpoints_data()))\r\n\r\n\r\n# GET /env\r\nclass EnvHandler(AbstractPyctuatorHandler):\r\n    def get(self) -> None:\r\n        assert self.pyctuator_router is not None\r\n        assert self.dumps is not None\r\n        self.write(self.dumps(self.pyctuator_router.pyctuator_impl.get_environment()))\r\n\r\n\r\n# GET /info\r\nclass InfoHandler(AbstractPyctuatorHandler):\r\n    def get(self) -> None:\r\n        assert self.pyctuator_router is not None\r\n        assert self.dumps is not None\r\n        self.write(self.dumps(self.pyctuator_router.pyctuator_impl.get_app_info()))\r\n\r\n\r\n# GET /health\r\nclass HealthHandler(AbstractPyctuatorHandler):\r\n    def get(self) -> None:\r\n        assert self.pyctuator_router is not None\r\n        assert self.dumps is not None\r\n        health = self.pyctuator_router.pyctuator_impl.get_health()\r\n        self.set_status(health.http_status())\r\n        self.write(self.dumps(health))\r\n\r\n\r\n# GET /metrics\r\nclass MetricsHandler(AbstractPyctuatorHandler):\r\n    def get(self) -> None:\r\n        assert self.pyctuator_router is not None\r\n        assert self.dumps is not None\r\n        self.write(self.dumps(self.pyctuator_router.pyctuator_impl.get_metric_names()))\r\n\r\n\r\n# GET \"/metrics/{metric_name}\"\r\nclass MetricsNameHandler(AbstractPyctuatorHandler):\r\n    def get(self, metric_name: str) -> None:\r\n        assert self.pyctuator_router is not None\r\n        assert self.dumps is not None\r\n        self.write(self.dumps(self.pyctuator_router.pyctuator_impl.get_metric_measurement(metric_name)))\r\n\r\n\r\n# GET /loggers\r\nclass LoggersHandler(AbstractPyctuatorHandler):\r\n    def get(self) -> None:\r\n        assert self.pyctuator_router is not None\r\n        assert self.dumps is not None\r\n        self.write(self.dumps(self.pyctuator_router.pyctuator_impl.logging.get_loggers()))\r\n\r\n\r\n# GET /loggers/{logger_name}\r\n# POST /loggers/{logger_name}\r\nclass LoggersNameHandler(AbstractPyctuatorHandler):\r\n    def get(self, logger_name: str) -> None:\r\n        assert self.pyctuator_router is not None\r\n        assert self.dumps is not None\r\n        self.write(self.dumps(self.pyctuator_router.pyctuator_impl.logging.get_logger(logger_name)))\r\n\r\n    def post(self, logger_name: str) -> None:\r\n        assert self.pyctuator_router is not None\r\n        assert self.dumps is not None\r\n        body_str = self.request.body.decode(\"utf-8\")\r\n        body = json.loads(body_str)\r\n        self.pyctuator_router.pyctuator_impl.logging.set_logger_level(logger_name, body.get(\"configuredLevel\", None))\r\n        self.write(\"\")\r\n\r\n\r\n# GET /threaddump\r\nclass ThreadDumpHandler(AbstractPyctuatorHandler):\r\n    def get(self) -> None:\r\n        assert self.pyctuator_router is not None\r\n        assert self.dumps is not None\r\n        self.write(self.dumps(self.pyctuator_router.pyctuator_impl.get_thread_dump()))\r\n\r\n\r\n# GET /logfile\r\nclass LogFileHandler(AbstractPyctuatorHandler):\r\n    def get(self) -> None:\r\n        assert self.pyctuator_router is not None\r\n        assert self.dumps is not None\r\n\r\n        range_header = self.request.headers.get(\"range\")\r\n        if not range_header:\r\n            self.write(f\"{self.pyctuator_router.pyctuator_impl.logfile.log_messages.get_range()}\")\r\n\r\n        else:\r\n            str_res, start, end = self.pyctuator_router.pyctuator_impl.logfile.get_logfile(range_header)\r\n            self.set_status(HTTPStatus.PARTIAL_CONTENT.value)\r\n            self.add_header(\"Content-Type\", \"text/html; charset=UTF-8\")\r\n            self.add_header(\"Accept-Ranges\", \"bytes\")\r\n            self.add_header(\"Content-Range\", f\"bytes {start}-{end}/{end}\")\r\n            self.write(str_res)\r\n\r\n\r\n# GET /httptrace\r\nclass HttpTraceHandler(AbstractPyctuatorHandler):\r\n    def get(self) -> None:\r\n        assert self.pyctuator_router is not None\r\n        assert self.dumps is not None\r\n        self.write(self.dumps(self.pyctuator_router.pyctuator_impl.http_tracer.get_httptrace()))\r\n\r\n\r\n# pylint: disable=too-many-locals,unused-argument\r\nclass TornadoHttpPyctuator(PyctuatorRouter):\r\n    def __init__(self, app: Application, pyctuator_impl: PyctuatorImpl, disabled_endpoints: Endpoints) -> None:\r\n        super().__init__(app, pyctuator_impl)\r\n\r\n        custom_dumps = partial(\r\n            json.dumps, default=self._custom_json_serializer\r\n        )\r\n\r\n        app.settings.setdefault(\"pyctuator_router\", self)\r\n        app.settings.setdefault(\"custom_dumps\", custom_dumps)\r\n\r\n        # Register a log-function that records request and response in traces and than delegates to the original func\r\n        self.delegate_log_function = app.settings.get(\"log_function\")\r\n        app.settings.setdefault(\"log_function\", self._intercept_request_and_response)\r\n\r\n        handlers: list = [(r\"/pyctuator\", PyctuatorHandler)]\r\n\r\n        if Endpoints.ENV not in disabled_endpoints:\r\n            handlers.append((r\"/pyctuator/env\", EnvHandler))\r\n\r\n        if Endpoints.INFO not in disabled_endpoints:\r\n            handlers.append((r\"/pyctuator/info\", InfoHandler))\r\n\r\n        if Endpoints.HEALTH not in disabled_endpoints:\r\n            handlers.append((r\"/pyctuator/health\", HealthHandler))\r\n\r\n        if Endpoints.METRICS not in disabled_endpoints:\r\n            handlers.append((r\"/pyctuator/metrics\", MetricsHandler))\r\n            handlers.append((r\"/pyctuator/metrics/(?P<metric_name>.*$)\", MetricsNameHandler))\r\n\r\n        if Endpoints.LOGGERS not in disabled_endpoints:\r\n            handlers.append((r\"/pyctuator/loggers\", LoggersHandler))\r\n            handlers.append((r\"/pyctuator/loggers/(?P<logger_name>.*$)\", LoggersNameHandler))\r\n\r\n        if Endpoints.THREAD_DUMP not in disabled_endpoints:\r\n            handlers.append((r\"/pyctuator/dump\", ThreadDumpHandler))\r\n            handlers.append((r\"/pyctuator/threaddump\", ThreadDumpHandler))\r\n\r\n        if Endpoints.LOGFILE not in disabled_endpoints:\r\n            handlers.append((r\"/pyctuator/logfile\", LogFileHandler))\r\n\r\n        if Endpoints.HTTP_TRACE not in disabled_endpoints:\r\n            handlers.append((r\"/pyctuator/trace\", HttpTraceHandler))\r\n            handlers.append((r\"/pyctuator/httptrace\", HttpTraceHandler))\r\n\r\n        app.add_handlers(\".*$\", handlers)\r\n\r\n    def _intercept_request_and_response(self, handler: RequestHandler) -> None:\r\n        # Record the request and response\r\n        record = TraceRecord(\r\n            timestamp=datetime.now() - timedelta(seconds=handler.request.request_time()),\r\n            principal=None,\r\n            session=None,\r\n            request=TraceRequest(\r\n                method=handler.request.method or \"\",\r\n                uri=handler.request.full_url(),\r\n                headers=get_headers(handler.request.headers)\r\n            ),\r\n            response=TraceResponse(\r\n                status=handler.get_status(),\r\n                headers=get_headers(handler._headers)  # pylint: disable=protected-access\r\n            ),\r\n            timeTaken=int(handler.request.request_time() * 1000),\r\n        )\r\n        self.pyctuator_impl.http_tracer.add_record(record)\r\n\r\n        if self.delegate_log_function:\r\n            self.delegate_log_function(handler)\r\n\r\n    def _custom_json_serializer(self, value: Any) -> Any:\r\n        if dataclasses.is_dataclass(value):\r\n            return dataclasses.asdict(value)\r\n\r\n        if isinstance(value, datetime):\r\n            return str(value)\r\n        return None\r\n\r\n\r\ndef get_headers(headers: HTTPHeaders) -> Mapping[str, List[str]]:\r\n    \"\"\" Tornado's HTTPHeaders contains multiple entries of the same header name if multiple values were used, this\r\n    function groups headers by header name. See documentation of `tornado.httputil.HTTPHeaders` \"\"\"\r\n    return {header.lower(): headers.get_list(header) for header in headers.keys()}\r\n"
  },
  {
    "path": "pyctuator/logfile/logfile.py",
    "content": "import logging\nimport re\nfrom typing import Optional, Tuple\n\nlogfile_request_range_pattern = re.compile(\"bytes=(\\\\d*)-(\\\\d*)\")\n\n\nclass LogMessageBuffer(logging.Handler):\n    def __init__(self, max_size: int, formatter: str) -> None:\n        super().__init__()\n        self.setFormatter(logging.Formatter(formatter))\n        self._max_size = max_size\n        self._buffer: str = \"\"\n        self._offset: int = 0\n\n    def emit(self, record: logging.LogRecord) -> None:\n        msg = self.format(record) + \"\\n\"\n        msg_len = len(msg)\n        if len(self._buffer) + msg_len > self._max_size:\n            self._buffer = self._buffer[-(self._max_size - msg_len):]\n            self._offset += msg_len\n        self._buffer += msg\n\n    def get_range(self, start: Optional[int] = None, end: Optional[int] = None) -> str:\n        start = start - self._offset if start else 0\n        return self._buffer[start:end or len(self._buffer) - 1]\n\n    def get_offset(self) -> int:\n        return self._offset\n\n    def get_offset_tuple(self, start: Optional[int], end: Optional[int]) -> Tuple[int, int]:\n        res_start = self._offset + (start or 0)\n        res_end = self._offset + (end or len(self._buffer))\n        return res_start, res_end\n\n\nclass PyctuatorLogfile:\n    def __init__(self, max_size: int, formatter: str) -> None:\n        self.log_messages = LogMessageBuffer(max_size=max_size, formatter=formatter)\n\n    def get_logfile(self, range_substring: str) -> Tuple[str, int, int]:\n        logging.debug(\"Received logfile request with range header: %s\", range_substring)\n\n        start = None\n        end = None\n        range_substring_match = logfile_request_range_pattern.match(range_substring)\n        if range_substring_match:\n            start_str, end_str = range_substring_match.groups()\n            start = int(start_str) if start_str.strip() else None\n            end = int(end_str) if end_str.strip() else None\n\n        str_res = self.log_messages.get_range(start, end)\n        end = len(str_res) if (start is None) and end else end  # Handle 0-307200 initial range edge-case\n\n        res_start, res_end = self.log_messages.get_offset_tuple(start, end)\n\n        logging.debug(f\"Returning logfile response with range header: bytes=%d-%d/%d\", res_start, res_end, res_end)\n\n        return str_res, res_start, res_end\n\n    def get_log_buffer_offset(self) -> int:\n        return self.log_messages.get_offset()\n"
  },
  {
    "path": "pyctuator/logging/__init__.py",
    "content": ""
  },
  {
    "path": "pyctuator/logging/pyctuator_logging.py",
    "content": "import logging\nfrom dataclasses import dataclass\nfrom typing import Dict, List, Optional\n\n\n@dataclass\nclass LoggerLevels:\n    configuredLevel: str\n    effectiveLevel: str\n\n\n@dataclass\nclass LoggersData:\n    levels: List[str]\n    loggers: Dict[str, LoggerLevels]\n    groups: Dict[str, LoggerLevels]\n\n\n@dataclass\nclass LogLevelMapping:\n    boot_admin_log_level: str\n    python_log_level: int\n    python_from_log_level: int\n\n\n_log_level_mapping: List[LogLevelMapping] = [\n    LogLevelMapping(\"DEBUG\", logging.DEBUG, logging.NOTSET),\n    LogLevelMapping(\"INFO\", logging.INFO, logging.DEBUG),\n    LogLevelMapping(\"WARN\", logging.WARNING, logging.INFO),\n    LogLevelMapping(\"ERROR\", logging.ERROR, logging.WARNING),\n    LogLevelMapping(\"OFF\", logging.NOTSET, -1),\n]\n\n\ndef _python_to_admin_log_level(log_level: int) -> str:\n    for mapping in _log_level_mapping:\n        if mapping.python_from_log_level < log_level <= mapping.python_log_level:\n            return mapping.boot_admin_log_level\n\n    # If log_level is unknown, simply return its string representation\n    return str(log_level)\n\n\ndef _admin_to_python_log_level(log_level: str) -> int:\n    log_level_mapping = next(mapping for mapping in _log_level_mapping if mapping.boot_admin_log_level == log_level)\n    return log_level_mapping.python_log_level\n\n\nclass PyctuatorLogging:\n    def set_logger_level(self, logger_name: str, logger_level: Optional[str]) -> None:\n        logger = logging.getLogger(logger_name)\n        level = logger_level or \"OFF\"\n\n        if level == \"OFF\":\n            logging.disable(logging.CRITICAL)  # disable all logging calls of (CRITICAL) severity lvl and below\n            logger.setLevel(0)\n        else:\n            logger.setLevel(_admin_to_python_log_level(level))\n        logging.debug(\"Setting logger '%s' level to %s\", logger_name, level)\n\n    def get_loggers(self) -> LoggersData:\n        level_names = [mapping.boot_admin_log_level for mapping\n                       in _log_level_mapping\n                       if mapping.boot_admin_log_level != \"OFF\"]\n\n        loggers = {}\n        for logger_dict_member in logging.root.manager.loggerDict:  # type: ignore\n            logger_inst = logging.getLogger(logger_dict_member)\n            level = _python_to_admin_log_level(logger_inst.level)\n            loggers[logger_inst.name] = LoggerLevels(level, level)\n\n        return LoggersData(levels=level_names, loggers=loggers, groups={})\n\n    def get_logger(self, logger_name: str) -> LoggerLevels:\n        logger = logging.getLogger(logger_name)\n        level = _python_to_admin_log_level(logger.level)\n        return LoggerLevels(level, level)\n"
  },
  {
    "path": "pyctuator/metrics/__init__.py",
    "content": ""
  },
  {
    "path": "pyctuator/metrics/memory_metrics_impl.py",
    "content": "# pylint: disable=import-outside-toplevel\nimport importlib.util\nfrom typing import List\n\nfrom pyctuator.metrics.metrics_provider import MetricsProvider, Metric, Measurement\n\nPREFIX = \"memory.\"\n\n\nclass MemoryMetricsProvider(MetricsProvider):\n    def __init__(self) -> None:\n        if importlib.util.find_spec(\"psutil\"):\n            # psutil is optional and must only be imported if it is installed\n            import psutil\n            self.process = psutil.Process()\n        else:\n            self.process = None\n\n    def get_prefix(self) -> str:\n        return PREFIX\n\n    def get_supported_metric_names(self) -> List[str]:\n        if not self.process:\n            return []\n        keys: List[str] = list(self.process.memory_info()._asdict().keys())\n        return list(map(lambda metric: PREFIX + metric, list(keys)))\n\n    def get_metric(self, metric_name: str) -> Metric:\n        measurements: List[Measurement] = []\n        if self.process:\n            name = metric_name[len(PREFIX):]\n            measurements = [Measurement(\"VALUE\", getattr(self.process.memory_info(), name))]\n        return Metric(metric_name, None, \"bytes\", measurements, [])\n"
  },
  {
    "path": "pyctuator/metrics/metrics_provider.py",
    "content": "from abc import ABC, abstractmethod\nfrom dataclasses import dataclass\n\nfrom typing import List, Optional\n\n\n@dataclass\nclass MetricNames:\n    names: List[str]\n\n\n@dataclass\nclass Measurement:\n    statistic: str  # one of TOTAL, TOTAL_TIME, COUNT, MAX, VALUE, UNKNOWN, ACTIVE_TASKS, DURATION\n    value: float  # can be an int as well\n\n\n@dataclass\nclass MetricTag:\n    tag: str\n    values: List[str]\n\n\n@dataclass\nclass Metric:\n    name: str\n    description: Optional[str]\n    baseUnit: str\n    measurements: List[Measurement]\n    availableTags: List[MetricTag]\n\n\nclass MetricsProvider(ABC):\n\n    @abstractmethod\n    def get_prefix(self) -> str:\n        pass\n\n    @abstractmethod\n    def get_supported_metric_names(self) -> List[str]:\n        pass\n\n    @abstractmethod\n    def get_metric(self, metric_name: str) -> Metric:\n        pass\n"
  },
  {
    "path": "pyctuator/metrics/thread_metrics_impl.py",
    "content": "# pylint: disable=import-outside-toplevel\nimport importlib.util\nfrom typing import List\n\nfrom pyctuator.metrics.metrics_provider import MetricsProvider, Metric, Measurement\n\nPREFIX = \"thread.\"\nTHREAD_COUNT = PREFIX + \"count\"\n\n\nclass ThreadMetricsProvider(MetricsProvider):\n    def __init__(self) -> None:\n        if importlib.util.find_spec(\"psutil\"):\n            # psutil is optional and must only be imported if it is installed\n            import psutil\n            self.process = psutil.Process()\n        else:\n            self.process = None\n\n    def get_prefix(self) -> str:\n        return PREFIX\n\n    def get_supported_metric_names(self) -> List[str]:\n        return [THREAD_COUNT] if self.process else []\n\n    def get_metric(self, metric_name: str) -> Metric:\n        measurements = [Measurement(\"COUNT\", self.process.num_threads())] if self.process else []\n        return Metric(metric_name, None, \"Integer\", measurements, [])\n"
  },
  {
    "path": "pyctuator/py.typed",
    "content": ""
  },
  {
    "path": "pyctuator/pyctuator.py",
    "content": "# pylint: disable=import-outside-toplevel\nimport atexit\nimport importlib.util\nimport logging\nimport ssl\nfrom datetime import datetime, timezone\nfrom typing import Any, Optional, Dict, Callable\n\n# A note about imports: this module ensure that only relevant modules are imported.\n# For example, if the webapp is a Flask webapp, we do not want to import FastAPI, and vice versa.\n# To do that, all imports are in conditional branches after detecting which frameworks are installed.\n# DO NOT add any web-framework-dependent imports to the global scope.\nfrom pyctuator.auth import Auth\nfrom pyctuator.endpoints import Endpoints\nfrom pyctuator.environment.custom_environment_provider import CustomEnvironmentProvider\nfrom pyctuator.environment.os_env_variables_impl import OsEnvironmentVariableProvider\nfrom pyctuator.health.diskspace_health_impl import DiskSpaceHealthProvider\nfrom pyctuator.health.health_provider import HealthProvider\nfrom pyctuator.metrics.memory_metrics_impl import MemoryMetricsProvider\nfrom pyctuator.metrics.thread_metrics_impl import ThreadMetricsProvider\nfrom pyctuator.impl.pyctuator_impl import PyctuatorImpl, AppInfo, BuildInfo, GitInfo, GitCommitInfo, AppDetails\nfrom pyctuator.impl.spring_boot_admin_registration import BootAdminRegistrationHandler\n\ndefault_logfile_format = '%(asctime)s  %(levelname)-5s %(process)d -- [%(threadName)s] %(module)s: %(message)s'\n\n\nclass Pyctuator:\n    # pylint: disable=too-many-locals\n    def __init__(\n            self,\n            app: Any,\n            app_name: str,\n            app_url: str,\n            pyctuator_endpoint_url: str,\n            registration_url: Optional[str],\n            registration_auth: Optional[Auth] = None,\n            app_description: Optional[str] = None,\n            registration_interval_sec: float = 10,\n            free_disk_space_down_threshold_bytes: int = 1024 * 1024 * 100,\n            logfile_max_size: int = 10000,\n            logfile_formatter: str = default_logfile_format,\n            auto_deregister: bool = True,\n            metadata: Optional[dict] = None,\n            additional_app_info: Optional[dict] = None,\n            ssl_context: Optional[ssl.SSLContext] = None,\n            customizer: Optional[Callable] = None,\n            disabled_endpoints: Endpoints = Endpoints.NONE,\n    ) -> None:\n        \"\"\"The entry point for integrating pyctuator with a web-frameworks such as FastAPI and Flask.\n\n        Given an application built on top of a supported web-framework, it'll add to the application the REST API\n         endpoints that required for Spring Boot Admin to monitor and manage the application.\n\n        Pyctuator currently supports application built on top of FastAPI and Flask. The type of first argument, app is\n         specific to the target web-framework:\n\n        * FastAPI - `app` is an instance of `fastapi.applications.FastAPI`\n\n        * Flask - `app` is an instance of `flask.app.Flask`\n\n        * aiohttp - `app` is an instance of `aiohttp.web.Application`\n\n        * Tornado - `app` is an instance of `tornado.web.Application`\n\n        :param app: an instance of a supported web-framework with which the pyctuator endpoints will be registered\n        :param app_name: the application's name that will be presented in the \"Info\" section in boot-admin\n        :param app_description: a description that will be presented in the \"Info\" section in boot-admin\n        :param app_url: the full URL of the application being monitored which will be displayed in spring-boot-admin, we\n         recommend this URL to be accessible by those who manage the application (i.e. don't use \"http://localhost...\"\n         as it is only accessible from within the application's host)\n        :param pyctuator_endpoint_url: the public URL from which Pyctuator REST API will be accessible, used for\n         registering the application with spring-boot-admin, must be accessible from spring-boot-admin server (i.e.\n         don't use http://localhost:8080/... unless spring-boot-admin is running on the same host as the monitored\n         application)\n        :param registration_url: the spring-boot-admin endpoint to which registration requests must be posted\n        :param registration_auth: optional authentication details to use when registering with spring-boot-admin\n        :param registration_interval_sec: how often pyctuator will renew its registration with spring-boot-admin\n        :param free_disk_space_down_threshold_bytes: amount of free space in bytes in \"./\" (the application's current\n         working directory) below which the built-in disk-space health-indicator will fail\n        :param auto_deregister: if true, pyctuator will automatically deregister from SBA during shutdown, needed for\n        example when running in k8s so every time a new pod is created it is assigned a different IP address, resulting\n        with SBA showing \"offline\" instances\n        :param metadata: optional metadata key-value pairs that are displayed in SBA main page of an instance\n        :param additional_app_info: additional arbitrary information to add to the application's \"Info\" section\n        :param ssl_context: optional SSL context to be used when registering with SBA\n        :param customizer: a function that can customize the integration with the web-framework which is therefore web-\n         framework specific. For FastAPI, the function receives pyctuator's APIRouter allowing to add \"dependencies\" and\n         anything else that's provided by the router. See fastapi_with_authentication_example_app.py\n         :param disabled_endpoints: optional set of endpoints (such as /pyctuator/health) that should be disabled\n        \"\"\"\n\n        self.auto_deregister = auto_deregister\n        start_time = datetime.now(timezone.utc)\n\n        # Instantiate an instance of PyctuatorImpl which abstracts the state and logic of the pyctuator\n        self.pyctuator_impl = PyctuatorImpl(\n            AppInfo(app=AppDetails(name=app_name, description=app_description)),\n            pyctuator_endpoint_url,\n            logfile_max_size,\n            logfile_formatter,\n            additional_app_info,\n            disabled_endpoints,\n        )\n\n        # Register default health/metrics/environment providers\n        self.pyctuator_impl.register_environment_provider(OsEnvironmentVariableProvider())\n        self.pyctuator_impl.register_health_providers(DiskSpaceHealthProvider(free_disk_space_down_threshold_bytes))\n        self.pyctuator_impl.register_metrics_provider(MemoryMetricsProvider())\n        self.pyctuator_impl.register_metrics_provider(ThreadMetricsProvider())\n\n        self.boot_admin_registration_handler: Optional[BootAdminRegistrationHandler] = None\n\n        self.metadata = metadata\n        self.ssl_context = ssl_context\n\n        root_logger = logging.getLogger()\n        # If application did not initiate logging module, add default handler to root logger\n        # logging.info implicitly calls logging.basicConfig(), see logging.basicConfig in Python's documentation.\n        if not root_logger.hasHandlers():\n            logging.info(\"Logging not configured, using logging.basicConfig()\")\n\n        root_logger.addHandler(self.pyctuator_impl.logfile.log_messages)\n\n        # Find and initialize an integration layer between the web-framework adn pyctuator\n        framework_integrations: Dict[str, Callable[[Any, PyctuatorImpl, Optional[Callable], Endpoints], bool]] = {\n            \"flask\": self._integrate_flask,\n            \"fastapi\": self._integrate_fastapi,\n            \"aiohttp\": self._integrate_aiohttp,\n            \"tornado\": self._integrate_tornado\n        }\n        for framework_name, framework_integration_function in framework_integrations.items():\n            if self._is_framework_installed(framework_name):\n                logging.debug(\"Framework %s is installed, trying to integrate with it\", framework_name)\n                success = framework_integration_function(app, self.pyctuator_impl, customizer, disabled_endpoints)\n                if success:\n                    logging.debug(\"Integrated with framework %s\", framework_name)\n                    if registration_url is not None:\n                        self.boot_admin_registration_handler = BootAdminRegistrationHandler(\n                            registration_url,\n                            registration_auth,\n                            app_name,\n                            self.pyctuator_impl.pyctuator_endpoint_url,\n                            start_time,\n                            app_url,\n                            registration_interval_sec,\n                            self.metadata,\n                            self.ssl_context,\n                        )\n\n                        # Deregister from SBA on exit\n                        if self.auto_deregister:\n                            atexit.register(self.boot_admin_registration_handler.deregister_from_admin_server)\n\n                        self.boot_admin_registration_handler.start()\n                    return\n\n        # Fail in case no framework was found for the target app\n        raise EnvironmentError(\"No framework was found that is matching the target app \"\n                               \"(is it properly installed and imported?)\")\n\n    def stop(self) -> None:\n        if self.boot_admin_registration_handler:\n            self.boot_admin_registration_handler.stop()\n        self.boot_admin_registration_handler = None\n\n    def set_secret_scrubber(self, secret_scrubber: Callable[[Dict], Dict]) -> None:\n        \"\"\"Overrides the default secret scrubber with a custom one. See SecretScrubber for example scrubber.\"\"\"\n        self.pyctuator_impl.set_secret_scrubber(secret_scrubber)\n\n    def register_environment_provider(self, name: str, env_provider: Callable[[], Dict]) -> None:\n        self.pyctuator_impl.register_environment_provider(CustomEnvironmentProvider(name, env_provider))\n\n    def register_health_provider(self, provider: HealthProvider) -> None:\n        self.pyctuator_impl.register_health_providers(provider)\n\n    def set_git_info(self, commit: str, time: datetime, branch: Optional[str] = None) -> None:\n        self.pyctuator_impl.set_git_info(GitInfo(GitCommitInfo(time, commit), branch))\n\n    def set_build_info(\n            self,\n            artifact: Optional[str] = None,\n            group: Optional[str] = None,\n            name: Optional[str] = None,\n            version: Optional[str] = None,\n            time: Optional[datetime] = None,\n    ) -> None:\n        self.pyctuator_impl.set_build_info(BuildInfo(name, artifact, group, version, time))\n\n    def _is_framework_installed(self, framework_name: str) -> bool:\n        return importlib.util.find_spec(framework_name) is not None\n\n    def _integrate_fastapi(\n            self,\n            app: Any,\n            pyctuator_impl: PyctuatorImpl,\n            customizer: Optional[Callable],\n            disabled_endpoints: Endpoints,\n    ) -> bool:\n        \"\"\"\n        This method should only be called if we detected that FastAPI is installed.\n        It will then check whether the given app is a FastAPI app, and if so - it will add the Pyctuator\n        endpoints to it.\n        \"\"\"\n        from fastapi import FastAPI\n        if isinstance(app, FastAPI):\n            from pyctuator.impl.fastapi_pyctuator import FastApiPyctuator\n            FastApiPyctuator(app, pyctuator_impl, False, customizer, disabled_endpoints)\n            return True\n        return False\n\n    # pylint: disable=unused-argument\n    def _integrate_flask(\n            self,\n            app: Any,\n            pyctuator_impl: PyctuatorImpl,\n            customizer: Optional[Callable],\n            disabled_endpoints: Endpoints,\n    ) -> bool:\n        \"\"\"\n        This method should only be called if we detected that Flask is installed.\n        It will then check whether the given app is a Flask app, and if so - it will add the Pyctuator\n        endpoints to it.\n        \"\"\"\n        from flask import Flask\n        if isinstance(app, Flask):\n            from pyctuator.impl.flask_pyctuator import FlaskPyctuator\n            FlaskPyctuator(app, pyctuator_impl, disabled_endpoints)\n            return True\n        return False\n\n    # pylint: disable=unused-argument\n    def _integrate_aiohttp(\n            self,\n            app: Any,\n            pyctuator_impl: PyctuatorImpl,\n            customizer: Optional[Callable],\n            disabled_endpoints: Endpoints,\n    ) -> bool:\n        \"\"\"\n        This method should only be called if we detected that aiohttp is installed.\n        It will then check whether the given app is a aiohttp app, and if so - it will add the Pyctuator\n        endpoints to it.\n        \"\"\"\n        from aiohttp.web import Application\n        if isinstance(app, Application):\n            from pyctuator.impl.aiohttp_pyctuator import AioHttpPyctuator\n            AioHttpPyctuator(app, pyctuator_impl, disabled_endpoints)\n            return True\n        return False\n\n    # pylint: disable=unused-argument\n    def _integrate_tornado(\n            self,\n            app: Any,\n            pyctuator_impl: PyctuatorImpl,\n            customizer: Optional[Callable],\n            disabled_endpoints: Endpoints,\n    ) -> bool:\n        \"\"\"\n        This method should only be called if we detected that tornado is installed.\n        It will then check whether the given app is a tornado app, and if so - it will add the Pyctuator\n        endpoints to it.\n        \"\"\"\n        from tornado.web import Application\n        if isinstance(app, Application):\n            from pyctuator.impl.tornado_pyctuator import TornadoHttpPyctuator\n            TornadoHttpPyctuator(app, pyctuator_impl, disabled_endpoints)\n            return True\n        return False\n"
  },
  {
    "path": "pyctuator/threads/__init__.py",
    "content": ""
  },
  {
    "path": "pyctuator/threads/thread_dump_provider.py",
    "content": "import sys\nimport threading\nfrom threading import Thread\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import List, Dict, Any, Optional\n\n\n@dataclass\nclass StackFrame:\n    methodName: str\n    fileName: str\n    lineNumber: int\n    className: Optional[str]\n    nativeMethod: bool\n\n\n@dataclass\nclass ThreadInfo:\n    threadName: str\n    threadId: Optional[int]\n    daemon: bool\n    suspended: bool\n    threadState: str\n    stackTrace: List[StackFrame]\n\n\n@dataclass\nclass ThreadDump:\n    threads: List[ThreadInfo]\n\n\nclass ThreadDumpProvider:\n\n    # pylint: disable=protected-access\n    def get_thread_dump(self) -> ThreadDump:\n        frames: Dict[Any, Any] = sys._current_frames()\n        return ThreadDump([\n            self._extract_thread_info(frames, thread)\n            for thread in threading.enumerate()\n        ])\n\n    def _extract_thread_info(self, frames: Dict[Any, Any], thread: Thread) -> ThreadInfo:\n        return ThreadInfo(\n            threadName=thread.name,\n            threadId=thread.ident,\n            daemon=thread.daemon,\n            suspended=not thread.is_alive(),\n            threadState=self._calc_thread_state(thread),\n            stackTrace=self._build_thread_stack_trace(thread, frames),\n        )\n\n    def _build_thread_stack_trace(self, thread: Thread, frames: Dict[Any, Any]) -> List[StackFrame]:\n\n        def guess_class_name() -> Optional[str]:\n            \"\"\"\n            Tries to find a class name if one exists.\n            Fails if the frame is not in a class, or if the method does not call itself \"self\"\n            Does not support static and class methods.\n            \"\"\"\n            try:\n                return str(frame.f_locals[\"self\"].__class__.__name__)\n            except KeyError:\n                return None\n\n        stack_frames = []\n        frame = frames[thread.ident] if thread.ident in frames else None\n        while frame is not None:\n            stack_frames.append(StackFrame(\n                methodName=frame.f_code.co_name,\n                fileName=Path(frame.f_code.co_filename).name,\n                lineNumber=frame.f_lineno,\n                className=guess_class_name(),\n                nativeMethod=False\n            ))\n            frame = frame.f_back  # move one frame back\n        return stack_frames\n\n    def _calc_thread_state(self, thread: threading.Thread) -> str:\n        if thread.ident and thread.ident < 0:\n            return \"NEW\"\n        return \"RUNNABLE\"\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.poetry]\nname = \"pyctuator\"\nversion = \"1.2.0\"\ndescription = \"A Python implementation of the Spring Actuator API for popular web frameworks\"\nauthors = [\n    \"Michael Yakobi <michael.yakobi@solaredge.com>\",\n    \"Inbal Levi <inbal.levi@solaredge.com>\",\n    \"Yanay Reingewertz <yanay.reingewertz@solaredge.com>\",\n    \"Matan Rubin <matan.rubin@solaredge.com>\"\n]\nmaintainers = [\n    \"Matan Rubin <matan.rubin@solaredge.com>\",\n    \"Michael Yakobi <michael.yakobi@solaredge.com>\"\n]\nreadme = \"README.md\"\nhomepage = \"https://github.com/SolarEdgeTech/pyctuator\"\nrepository = \"https://github.com/SolarEdgeTech/pyctuator\"\nkeywords = [\"spring boot admin\", \"actuator\", \"pyctuator\", \"fastapi\", \"flask\", \"aiohttp\", \"tornado\"]\n\nclassifiers = [\n    \"Development Status :: 4 - Beta\",\n    \"Environment :: Web Environment\",\n    \"Framework :: Flask\",\n    \"Framework :: FastAPI\",\n    \"Framework :: aiohttp\",\n    \"Intended Audience :: Developers\",\n    \"Programming Language :: Python :: 3.9\",\n    \"Topic :: Internet :: WWW/HTTP :: HTTP Servers\",\n    \"Topic :: Internet :: WWW/HTTP :: WSGI :: Application\",\n    \"Topic :: Software Development :: Libraries :: Python Modules\",\n    \"Topic :: System :: Monitoring\",\n    \"Typing :: Typed\",\n    \"License :: OSI Approved :: Apache Software License\",\n]\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\npsutil = { version = \"^5.6\", optional = true }\nflask = { version = \"^2.3.0\", optional = true }\nfastapi = { version = \"^0.100.1\", optional = true }\nuvicorn = { version = \"^0.23.0\", optional = true }\nsqlalchemy = {version = \"^2.0.4\", optional = true}\nPyMySQL = {version = \"^1.0.2\", optional = true}\ncryptography = {version = \">=39.0.1,<40.0.0\", optional = true}\nredis = {version = \"^4.3.4\", optional = true}\naiohttp = {version = \"^3.6.2\", optional = true}\ntornado = {version = \"^6.0.4\", optional = true}\n\n[tool.poetry.dev-dependencies]\nrequests = \"^2.22\"\npytest = \"^7.1.3\"\nmypy = \"^1.0.1\"\npylint = \"^2.15.0\"   # v2.5 does not properly run on docker image...\npytest-cov = \"^4.0.0\"\nautopep8 = \"^2.0.0\"\n\n[tool.poetry.extras]\npsutil = [\"psutil\"]\nfastapi = [\"fastapi\", \"uvicorn\"]\nflask = [\"flask\"]\naiohttp = [\"aiohttp\"]\ntornado = [\"tornado\"]\ndb = [\"sqlalchemy\", \"PyMySQL\", \"cryptography\"]\nredis = [\"redis\"]\n\n[build-system]\nrequires = [\"poetry>=1.1\"]\nbuild-backend = \"poetry.masonry.api\"\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/aiohttp_test_server.py",
    "content": "import asyncio\nimport logging\nimport threading\nimport time\n\nfrom aiohttp import web\n\nfrom pyctuator.endpoints import Endpoints\nfrom pyctuator.pyctuator import Pyctuator\nfrom tests.conftest import PyctuatorServer\n\nbind_port = 6000\n\n\n# mypy: ignore_errors\n# pylint: disable=unused-variable\nclass AiohttpPyctuatorServer(PyctuatorServer):\n\n    def __init__(self, disabled_endpoints: Endpoints = Endpoints.NONE) -> None:\n        global bind_port\n        self.port = bind_port\n        bind_port += 1\n\n        self.app = web.Application()\n        self.routes = web.RouteTableDef()\n\n        self.pyctuator = Pyctuator(\n            self.app,\n            \"AIOHTTP Pyctuator\",\n            f\"http://localhost:{self.port}\",\n            f\"http://localhost:{self.port}/pyctuator\",\n            \"http://localhost:8001/register\",\n            registration_interval_sec=1,\n            metadata=self.metadata,\n            additional_app_info=self.additional_app_info,\n            disabled_endpoints=disabled_endpoints,\n        )\n\n        @self.routes.get(\"/logfile_test_repeater\")\n        async def logfile_test_repeater(request: web.Request) -> web.Response:\n            repeated_string = request.query.get(\"repeated_string\")\n            logging.error(repeated_string)\n            return web.Response(text=repeated_string)\n\n        @self.routes.get(\"/httptrace_test_url\")\n        async def get_httptrace_test_url(request: web.Request) -> web.Response:\n            # Sleep if requested to sleep - used for asserting httptraces timing\n            sleep_sec = request.query.get(\"sleep_sec\")\n            if sleep_sec:\n                logging.info(\"Sleeping %s seconds before replying\", sleep_sec)\n                time.sleep(int(sleep_sec))\n\n            # Echo 'User-Data' header as 'resp-data' - used for asserting headers are captured properly\n            headers = {\n                \"resp-data\": str(request.headers.get(\"User-Data\")),\n                \"response-secret\": \"my password\"\n            }\n            return web.Response(headers=headers, body=\"my content\")\n\n        self.app.add_routes(self.routes)\n\n        self.thread = threading.Thread(target=self._start_in_thread)\n        self.should_stop_server = False\n        self.server_started = False\n\n    async def _run_server(self) -> None:\n        logging.info(\"Preparing to start aiohttp server\")\n        runner = web.AppRunner(self.app)\n        await runner.setup()\n\n        logging.info(\"Starting aiohttp server\")\n        site = web.TCPSite(runner, port=self.port)\n        await site.start()\n        self.server_started = True\n        logging.info(\"aiohttp server started\")\n\n        while not self.should_stop_server:\n            await asyncio.sleep(1)\n\n        logging.info(\"Shutting down aiohttp server\")\n        await runner.shutdown()\n        await runner.cleanup()\n        logging.info(\"aiohttp server is shutdown\")\n\n    def _start_in_thread(self) -> None:\n        loop = asyncio.new_event_loop()\n        loop.run_until_complete(self._run_server())\n        loop.stop()\n\n    def start(self) -> None:\n        self.thread.start()\n        while not self.server_started:\n            time.sleep(0.01)\n\n    def stop(self) -> None:\n        logging.info(\"Stopping aiohttp server\")\n        self.pyctuator.stop()\n        self.should_stop_server = True\n        self.thread.join()\n        logging.info(\"aiohttp server stopped\")\n\n    def atexit(self) -> None:\n        if self.pyctuator.boot_admin_registration_handler:\n            self.pyctuator.boot_admin_registration_handler.deregister_from_admin_server()\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "import logging\nimport random\nimport secrets\nimport threading\nimport time\nfrom abc import ABC, abstractmethod\nfrom dataclasses import dataclass\nfrom datetime import datetime, timezone\nfrom typing import Generator, Optional, Dict\n\nimport pytest\nimport requests\nfrom fastapi import FastAPI, Depends, HTTPException\nfrom fastapi.security import HTTPBasic, HTTPBasicCredentials\nfrom pydantic import BaseModel\nfrom starlette import status\nfrom uvicorn.config import Config\nfrom uvicorn.main import Server\n\nfrom pyctuator.endpoints import Endpoints\n\nREQUEST_TIMEOUT = 10\n\n\nclass RegistrationRequest(BaseModel):\n    name: str\n    managementUrl: str\n    healthUrl: str\n    serviceUrl: str\n    metadata: dict\n\n\n@dataclass\n# pylint: disable=too-many-instance-attributes\nclass RegisteredEndpoints:\n    root: str\n    pyctuator: str\n    env: Optional[str]\n    info: Optional[str]\n    health: Optional[str]\n    metrics: Optional[str]\n    loggers: Optional[str]\n    threads: Optional[str]\n    logfile: Optional[str]\n    httptrace: Optional[str]\n\n\n@dataclass\nclass RegistrationTrackerFixture:\n    registration: Optional[RegistrationRequest]\n    count: int\n    start_time: Optional[str]\n    deregistration_time: Optional[datetime]\n    test_start_time: datetime\n\n\nendpoint_href_path = {\n    Endpoints.ENV: \"env\",\n    Endpoints.INFO: \"info\",\n    Endpoints.HEALTH: \"health\",\n    Endpoints.METRICS: \"metrics\",\n    Endpoints.LOGGERS: \"loggers\",\n    Endpoints.THREAD_DUMP: \"threaddump\",\n    Endpoints.LOGFILE: \"logfile\",\n    Endpoints.HTTP_TRACE: \"httptrace\",\n}\n\n\nclass CustomServer(Server):\n    def install_signal_handlers(self) -> None:\n        pass\n\n\n@pytest.fixture\ndef registration_tracker() -> RegistrationTrackerFixture:\n    return RegistrationTrackerFixture(None, 0, None, None, datetime.now(timezone.utc))\n\n\n@pytest.fixture\ndef boot_admin_server(registration_tracker: RegistrationTrackerFixture) -> Generator:\n    boot_admin_app = FastAPI(\n        title=\"Boot Admin Mock Server\",\n        description=\"Demonstrate Spring Boot Admin Integration with FastAPI\",\n        docs_url=\"/api\",\n    )\n\n    security = HTTPBasic()\n\n    def get_current_username(credentials: HTTPBasicCredentials = Depends(security)) -> str:\n        correct_username = secrets.compare_digest(credentials.username, \"moo\")\n        correct_password = secrets.compare_digest(credentials.password, \"haha\")\n        if not (correct_username and correct_password):\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"Moo haha\",\n            )\n        return credentials.username\n\n    # pylint: disable=unused-variable\n    @boot_admin_app.post(\"/register\", tags=[\"admin-server\"])\n    def register(registration: RegistrationRequest) -> Dict[str, str]:\n        logging.debug(\"Got registration post %s, %d registrations since %s\",\n                      registration, registration_tracker.count, registration_tracker.start_time)\n        registration_tracker.registration = registration\n        registration_tracker.count += 1\n        if registration_tracker.start_time is None:\n            registration_tracker.start_time = registration.metadata[\"startup\"]\n        return {\"id\": \"JB007\"}\n\n    # pylint: disable=unused-variable\n    @boot_admin_app.post(\"/register-with-basic-auth\", tags=[\"admin-server\"])\n    def register_with_basic_auth(\n            registration: RegistrationRequest,\n            username: str = Depends(get_current_username)) -> Dict[str, str]:\n        logging.debug(\"Got registration post %s from %s, %d registrations since %s\",\n                      registration, username, registration_tracker.count, registration_tracker.start_time)\n        registration_tracker.registration = registration\n        registration_tracker.count += 1\n        if registration_tracker.start_time is None:\n            registration_tracker.start_time = registration.metadata[\"startup\"]\n        return {\"id\": \"JB007\"}\n\n    # pylint: disable=unused-argument,unused-variable\n    @boot_admin_app.delete(\"/register/{registration_id}\", tags=[\"admin-server\"])\n    def deregister(registration_id: str) -> None:\n        logging.debug(\"Got deregistration, delete %s (previous deregistration time is %s)\",\n                      registration_id, registration_tracker.deregistration_time)\n        registration_tracker.deregistration_time = datetime.now(timezone.utc)\n\n    # Start the mock boot-admin server that is needed to test pyctuator's registration\n    boot_admin_config = Config(app=boot_admin_app, port=8001, lifespan=\"off\", log_level=\"info\")\n    boot_admin_server = CustomServer(config=boot_admin_config)\n    boot_admin_thread = threading.Thread(target=boot_admin_server.run)\n    boot_admin_thread.start()\n    while not boot_admin_server.started:\n        time.sleep(0.01)\n    logging.info(\"Spring-boot-admin mock-server started\")\n\n    # Yield back to pytest until the module is done\n    yield None\n\n    logging.info(\"Stopping spring-boot-admin mock-server\")\n    boot_admin_server.should_exit = True\n    boot_admin_server.force_exit = True\n    boot_admin_thread.join()\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\")\n@pytest.fixture\ndef registered_endpoints(registration_tracker: RegistrationTrackerFixture) -> RegisteredEndpoints:\n    # time.sleep(600)\n    # Wait for pyctuator to register with the boot-admin at least once\n    while registration_tracker.registration is None:\n        time.sleep(0.01)\n\n    assert isinstance(registration_tracker.registration, RegistrationRequest)\n\n    response = requests.get(registration_tracker.registration.managementUrl, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == 200\n\n    links = response.json()[\"_links\"]\n\n    def link_href(endpoint: Endpoints) -> Optional[str]:\n        link = endpoint_href_path.get(endpoint)\n        return str(links[link][\"href\"]) if link in links else None\n\n    return RegisteredEndpoints(\n        root=registration_tracker.registration.serviceUrl,\n        pyctuator=links[\"self\"][\"href\"],\n        env=link_href(Endpoints.ENV),\n        info=link_href(Endpoints.INFO),\n        health=link_href(Endpoints.HEALTH),\n        metrics=link_href(Endpoints.METRICS),\n        loggers=link_href(Endpoints.LOGGERS),\n        threads=link_href(Endpoints.THREAD_DUMP),\n        logfile=link_href(Endpoints.LOGFILE),\n        httptrace=link_href(Endpoints.HTTP_TRACE),\n    )\n\n\nclass PyctuatorServer(ABC):\n    metadata: Optional[dict] = {f\"k{i}\": f\"v{i}\" for i in range(random.randrange(10))}\n    additional_app_info = {\n        \"serviceLinks\": {\n            \"metrics\": \"http://xyz/service/metrics\",\n        },\n        \"podLinks\": {\n            \"metrics\": [\"http://xyz/pod/metrics/memory\", \"http://xyz/pod/metrics/cpu\"],\n        },\n    }\n\n    @abstractmethod\n    def start(self) -> None:\n        pass\n\n    @abstractmethod\n    def stop(self) -> None:\n        pass\n\n    @abstractmethod\n    def atexit(self) -> None:\n        pass\n"
  },
  {
    "path": "tests/environment/__init__.py",
    "content": ""
  },
  {
    "path": "tests/environment/test_custom_environment_provider.py",
    "content": "from typing import Dict\n\nfrom pyctuator.environment.custom_environment_provider import CustomEnvironmentProvider\nfrom pyctuator.environment.environment_provider import PropertyValue\nfrom pyctuator.environment.scrubber import SecretScrubber\n\n\ndef test_custom_environment_provider() -> None:\n    def produce_env() -> Dict:\n        return {\n            \"a\": \"s1\",\n            \"b\": {\n                \"secret\": \"ha ha\",\n                \"c\": 625,\n            },\n            \"d\": {\n                \"e\": True,\n                \"f\": \"hello\",\n                \"g\": {\n                    \"h\": 123,\n                    \"i\": \"abcde\"\n                }\n            }\n        }\n\n    provider = CustomEnvironmentProvider(\"custom\", produce_env)\n    properties_source = provider.get_properties_source(SecretScrubber().scrub_secrets)\n    assert properties_source.name == \"custom\"\n    assert properties_source.properties == {\n        \"a\": PropertyValue(value=\"s1\"),\n        \"b.secret\": PropertyValue(value=\"******\"),\n        \"b.c\": PropertyValue(value=625),\n        \"d.e\": PropertyValue(value=True),\n        \"d.f\": PropertyValue(value=\"hello\"),\n        \"d.g.h\": PropertyValue(value=123),\n        \"d.g.i\": PropertyValue(value=\"abcde\"),\n    }\n"
  },
  {
    "path": "tests/environment/test_scrubber.py",
    "content": "import re\n\nfrom pyctuator.environment.scrubber import SecretScrubber\n\n\ndef test_scrub_secrets() -> None:\n    with_secrets = {\n        \"some.value\": \"Good\",\n        \"another.value\": 10,\n        \"another.value.and.another\": 10.0,\n        \"a.boolean\": True,\n        \"some.api_key\": \"Bad\",\n        \"a.key\": \"Bad\",\n        \"a.keyboard\": \"Good\",\n        \"db.url.1\": \"mysql+pymysql://user:Bad@host:3306/schema\",\n        \"db.url.2\": \"mysql+pymysql://joe:Bad@host/schema\",\n        \"db.url.3\": \"mysql+pymysql://joe:Bad@host\",\n        \"db.url.4\": \"mysql+pymysql://host\",\n    }\n\n    expected_without_secrets = {\n        \"some.value\": \"Good\",\n        \"another.value\": 10,\n        \"another.value.and.another\": 10.0,\n        \"a.boolean\": True,\n        \"some.api_key\": \"******\",\n        \"a.key\": \"******\",\n        \"a.keyboard\": \"Good\",\n        \"db.url.1\": \"mysql+pymysql://user:******@host:3306/schema\",\n        \"db.url.2\": \"mysql+pymysql://joe:******@host/schema\",\n        \"db.url.3\": \"mysql+pymysql://joe:******@host\",\n        \"db.url.4\": \"mysql+pymysql://host\",\n    }\n\n    scrubbed = SecretScrubber().scrub_secrets(with_secrets)\n    assert scrubbed == expected_without_secrets\n\n\ndef test_custom_scrub_secrets() -> None:\n    with_secrets = {\n        \"some.value\": \"Good\",\n        \"another.value\": 10,\n        \"another.value.and.another\": 10.0,\n        \"a.boolean\": True,\n        \"some.api_key\": \"Bad\",\n        \"a.key\": \"Bad\",\n        \"a.keyboard\": \"Good\",\n    }\n\n    expected_without_secrets = {\n        \"some.value\": \"******\",\n        \"another.value\": 10,\n        \"another.value.and.another\": 10.0,\n        \"a.boolean\": \"******\",\n        \"some.api_key\": \"Bad\",\n        \"a.key\": \"Bad\",\n        \"a.keyboard\": \"Good\",\n    }\n\n    scrubbed = SecretScrubber(keys_to_scrub=re.compile(\"^SOME.VALUE$|^a.BOOlean$\", re.IGNORECASE))\\\n        .scrub_secrets(with_secrets)\n    assert scrubbed == expected_without_secrets\n"
  },
  {
    "path": "tests/fast_api_test_server.py",
    "content": "import logging\nimport threading\nimport time\nfrom typing import Optional\n\nfrom fastapi import FastAPI\nfrom starlette.requests import Request\nfrom starlette.responses import Response\nfrom uvicorn.config import Config\n\nfrom pyctuator.endpoints import Endpoints\nfrom pyctuator.pyctuator import Pyctuator\nfrom tests.conftest import PyctuatorServer, CustomServer\n\nbind_port = 7000\n\n\nclass FastApiPyctuatorServer(PyctuatorServer):\n    def __init__(self, disabled_endpoints: Endpoints = Endpoints.NONE) -> None:\n        global bind_port\n        self.port = bind_port\n        bind_port += 1\n\n        self.app = FastAPI(\n            title=\"FastAPI Example Server\",\n            description=\"Demonstrate Spring Boot Admin Integration with FastAPI\",\n            docs_url=\"/api\",\n        )\n\n        self.pyctuator = Pyctuator(\n            self.app,\n            \"FastAPI Pyctuator\",\n            f\"http://localhost:{self.port}\",\n            f\"http://localhost:{self.port}/pyctuator\",\n            \"http://localhost:8001/register\",\n            registration_interval_sec=1,\n            metadata=self.metadata,\n            additional_app_info=self.additional_app_info,\n            disabled_endpoints=disabled_endpoints,\n        )\n\n        @self.app.get(\"/logfile_test_repeater\", tags=[\"pyctuator\"])\n        # pylint: disable=unused-variable\n        def logfile_test_repeater(repeated_string: str) -> str:\n            logging.error(repeated_string)\n            return repeated_string\n\n        self.server = CustomServer(config=Config(app=self.app, port=self.port, lifespan=\"off\", log_level=\"info\"))\n        self.thread = threading.Thread(target=self.server.run)\n\n        @self.app.get(\"/httptrace_test_url\")\n        # pylint: disable=unused-variable\n        def get_httptrace_test_url(request: Request, sleep_sec: Optional[int] = None) -> Response:\n            # Sleep if requested to sleep - used for asserting httptraces timing\n            if sleep_sec:\n                logging.info(\"Sleeping %s seconds before replying\", sleep_sec)\n                time.sleep(sleep_sec)\n\n            # Echo 'User-Data' header as 'resp-data' - used for asserting headers are captured properly\n            headers = {\n                \"resp-data\": str(request.headers.get(\"User-Data\")),\n                \"response-secret\": \"my password\"\n            }\n            return Response(headers=headers, content=\"my content\")\n\n    def start(self) -> None:\n        self.thread.start()\n        while not self.server.started:\n            time.sleep(0.01)\n\n    def stop(self) -> None:\n        logging.info(\"Stopping FastAPI server\")\n        self.pyctuator.stop()\n\n        # Allow the recurring registration to complete any in-progress request before stopping FastAPI\n        time.sleep(1)\n\n        self.server.should_exit = True\n        self.server.force_exit = True\n        self.thread.join()\n        logging.info(\"FastAPI server stopped\")\n\n    def atexit(self) -> None:\n        if self.pyctuator.boot_admin_registration_handler:\n            self.pyctuator.boot_admin_registration_handler.deregister_from_admin_server()\n"
  },
  {
    "path": "tests/flask_test_server.py",
    "content": "import logging\nimport threading\nimport time\nfrom wsgiref.simple_server import make_server\n\nimport requests\nfrom flask import Flask, request, Response\n\nfrom pyctuator.endpoints import Endpoints\nfrom pyctuator.pyctuator import Pyctuator\nfrom tests.conftest import PyctuatorServer\n\nREQUEST_TIMEOUT = 10\n\nbind_port = 5000\n\n\nclass FlaskPyctuatorServer(PyctuatorServer):\n    def __init__(self, disabled_endpoints: Endpoints = Endpoints.NONE) -> None:\n        global bind_port\n        self.port = bind_port\n        bind_port += 1\n\n        self.app = Flask(\"Flask Example Server\")\n        self.server = make_server('127.0.0.1', self.port, self.app)\n        self.ctx = self.app.app_context()\n        self.ctx.push()\n\n        self.thread = threading.Thread(target=self.server.serve_forever)\n\n        self.pyctuator = Pyctuator(\n            self.app,\n            \"Flask Pyctuator\",\n            f\"http://localhost:{self.port}\",\n            f\"http://localhost:{self.port}/pyctuator\",\n            \"http://localhost:8001/register\",\n            registration_interval_sec=1,\n            metadata=self.metadata,\n            additional_app_info=self.additional_app_info,\n            disabled_endpoints=disabled_endpoints,\n        )\n\n        @self.app.route(\"/logfile_test_repeater\")\n        # pylint: disable=unused-variable\n        def logfile_test_repeater() -> str:\n            repeated_string: str = str(request.args.get(\"repeated_string\"))\n            logging.error(repeated_string)\n            return repeated_string\n\n        @self.app.route(\"/httptrace_test_url\", methods=[\"GET\"])\n        # pylint: disable=unused-variable\n        def get_httptrace_test_url() -> Response:\n            # Sleep if requested to sleep - used for asserting httptraces timing\n            sleep_sec = request.args.get(\"sleep_sec\")\n            if sleep_sec:\n                logging.info(\"Sleeping %s seconds before replying\", sleep_sec)\n                time.sleep(int(sleep_sec))\n\n            # Echo 'User-Data' header as 'resp-data' - used for asserting headers are captured properly\n            resp = Response()\n            resp.headers[\"resp-data\"] = str(request.headers.get(\"User-Data\"))\n            resp.headers[\"response-secret\"] = str(\n                request.headers.get(\"my password\"))\n            return resp\n\n    def start(self) -> None:\n        logging.info(\"Starting Flask server\")\n        self.thread.start()\n        while True:\n            time.sleep(0.5)\n            try:\n                requests.get(f\"http://localhost:{self.port}/pyctuator\", timeout=REQUEST_TIMEOUT)\n                logging.info(\"Flask server started\")\n                return\n            except requests.exceptions.RequestException:  # Catches all exceptions that Requests raises!\n                pass\n\n    def stop(self) -> None:\n        logging.info(\"Stopping Flask server\")\n        self.pyctuator.stop()\n        self.server.shutdown()\n        self.thread.join()\n        logging.info(\"Flask server stopped\")\n\n    def atexit(self) -> None:\n        if self.pyctuator.boot_admin_registration_handler:\n            self.pyctuator.boot_admin_registration_handler.deregister_from_admin_server()\n"
  },
  {
    "path": "tests/health/__init__.py",
    "content": ""
  },
  {
    "path": "tests/health/test_composite_health_provider.py",
    "content": "from dataclasses import dataclass\n\nfrom pyctuator.health.composite_health_provider import CompositeHealthProvider, CompositeHealthStatus\nfrom pyctuator.health.health_provider import HealthProvider, HealthStatus, Status, HealthDetails\n\n\n@dataclass\nclass CustomHealthDetails(HealthDetails):\n    details: str\n\n\nclass CustomHealthProvider(HealthProvider):\n\n    def __init__(self, name: str, status: HealthStatus) -> None:\n        super().__init__()\n        self.name = name\n        self.status = status\n\n    def is_supported(self) -> bool:\n        return True\n\n    def get_name(self) -> str:\n        return self.name\n\n    def get_health(self) -> HealthStatus:\n        return self.status\n\n\ndef test_composite_health_provider_no_providers() -> None:\n    health_provider = CompositeHealthProvider(\n        \"comp1\",\n    )\n\n    assert health_provider.get_name() == \"comp1\"\n\n    assert health_provider.get_health() == CompositeHealthStatus(\n        status=Status.UP,\n        details={}\n    )\n\n\ndef test_composite_health_provider_all_up() -> None:\n    health_provider = CompositeHealthProvider(\n        \"comp2\",\n        CustomHealthProvider(\"hp1\", HealthStatus(Status.UP, CustomHealthDetails(\"d1\"))),\n        CustomHealthProvider(\"hp2\", HealthStatus(Status.UP, CustomHealthDetails(\"d2\"))),\n    )\n\n    assert health_provider.get_name() == \"comp2\"\n\n    assert health_provider.get_health() == CompositeHealthStatus(\n        status=Status.UP,\n        details={\n            \"hp1\": HealthStatus(Status.UP, CustomHealthDetails(\"d1\")),\n            \"hp2\": HealthStatus(Status.UP, CustomHealthDetails(\"d2\")),\n        }\n    )\n\n\ndef test_composite_health_provider_one_down() -> None:\n    health_provider = CompositeHealthProvider(\n        \"comp3\",\n        CustomHealthProvider(\"hp1\", HealthStatus(Status.UP, CustomHealthDetails(\"d1\"))),\n        CustomHealthProvider(\"hp2\", HealthStatus(Status.DOWN, CustomHealthDetails(\"d2\"))),\n    )\n\n    assert health_provider.get_name() == \"comp3\"\n\n    assert health_provider.get_health() == CompositeHealthStatus(\n        status=Status.DOWN,\n        details={\n            \"hp1\": HealthStatus(Status.UP, CustomHealthDetails(\"d1\")),\n            \"hp2\": HealthStatus(Status.DOWN, CustomHealthDetails(\"d2\")),\n        }\n    )\n"
  },
  {
    "path": "tests/health/test_db_health_provider.py",
    "content": "# pylint: disable=import-outside-toplevel\nimport importlib.util\nimport os\n\nimport pytest\n\n\n@pytest.fixture\ndef require_sql_alchemy() -> None:\n    if not importlib.util.find_spec(\"sqlalchemy\"):\n        pytest.skip(\"sqlalchemy is missing, skipping\")\n\n\n@pytest.fixture\ndef require_pymysql() -> None:\n    if not importlib.util.find_spec(\"pymysql\"):\n        pytest.skip(\"PyMySQL is missing, skipping\")\n\n\n@pytest.fixture\ndef require_mysql_server() -> None:\n    should_test_with_mysql = os.getenv(\"TEST_MYSQL_SERVER\", None)\n    if not should_test_with_mysql:\n        pytest.skip(\"No MySQL server (env TEST_MYSQL_SERVER isn't True), skipping\")\n\n\n@pytest.mark.usefixtures(\"require_sql_alchemy\")\ndef test_sqlite_health() -> None:\n    from sqlalchemy import create_engine\n    from pyctuator.health.db_health_provider import DbHealthProvider, DbHealthDetails, DbHealthStatus\n    from pyctuator.health.health_provider import Status\n\n    engine = create_engine(\"sqlite:///:memory:\", echo=True)\n    health_provider = DbHealthProvider(engine)\n    assert health_provider.get_health() == DbHealthStatus(status=Status.UP, details=DbHealthDetails(\"sqlite\"))\n    assert health_provider.get_name() == \"db\"\n    assert DbHealthProvider(engine, \"kuki\").get_name() == \"kuki\"\n\n\n@pytest.mark.usefixtures(\"require_sql_alchemy\", \"require_pymysql\", \"require_mysql_server\")\ndef test_mysql_health() -> None:\n    from sqlalchemy import create_engine\n    from sqlalchemy.engine import Engine\n    from pyctuator.health.db_health_provider import DbHealthProvider, DbHealthDetails, DbHealthStatus\n    from pyctuator.health.health_provider import Status\n\n    engine: Engine = create_engine(\"mysql+pymysql://root:root@localhost:3306\", echo=True)\n    health_provider = DbHealthProvider(engine)\n    assert health_provider.get_health() == DbHealthStatus(status=Status.UP, details=DbHealthDetails(\"mysql\"))\n\n    engine = create_engine(\"mysql+pymysql://kukipuki:blahblah@localhost:3306\", echo=True)\n    health_provider = DbHealthProvider(engine)\n    health = health_provider.get_health()\n    assert health.status == Status.DOWN\n    details: DbHealthDetails = health.details\n    assert details.failure is not None\n    assert \"Access denied for user\" in details.failure\n"
  },
  {
    "path": "tests/health/test_health_status.py",
    "content": "import pytest\n\nfrom pyctuator.endpoints import Endpoints\nfrom pyctuator.health.health_provider import HealthStatus, Status, HealthDetails, HealthProvider\nfrom pyctuator.impl.pyctuator_impl import PyctuatorImpl, AppInfo, AppDetails\nfrom pyctuator.pyctuator import default_logfile_format\n\n\nclass MyHealthProvider(HealthProvider):\n    def __init__(self, name: str = \"kuki\") -> None:\n        self.name = name\n        self.status = Status.UNKNOWN\n\n    def down(self) -> None:\n        self.status = Status.DOWN\n\n    def up(self) -> None:\n        self.status = Status.UP\n\n    def is_supported(self) -> bool:\n        return True\n\n    def get_health(self) -> HealthStatus:\n        return HealthStatus(self.status, HealthDetails())\n\n    def get_name(self) -> str:\n        return self.name\n\n\n@pytest.fixture\ndef pyctuator_impl() -> PyctuatorImpl:\n    return PyctuatorImpl(\n        AppInfo(app=AppDetails(name=\"appy\")),\n        \"http://appy/pyctuator\",\n        10,\n        default_logfile_format,\n        None,\n        Endpoints.NONE,\n    )\n\n\ndef test_health_status_single_provider(pyctuator_impl: PyctuatorImpl) -> None:\n    health_provider = MyHealthProvider()\n    pyctuator_impl.register_health_providers(health_provider)\n\n    # Test's default status is UNKNOWN\n    assert pyctuator_impl.get_health().status == Status.UNKNOWN\n\n    health_provider.down()\n    assert pyctuator_impl.get_health().status == Status.DOWN\n\n    health_provider.up()\n    assert pyctuator_impl.get_health().status == Status.UP\n\n\ndef test_health_status_multiple_providers(pyctuator_impl: PyctuatorImpl) -> None:\n    health_providers = [MyHealthProvider(\"kuki\"), MyHealthProvider(\"puki\"), MyHealthProvider(\"ruki\")]\n    for health_provider in health_providers:\n        pyctuator_impl.register_health_providers(health_provider)\n\n    # Test's default status is UNKNOWN - all 3 are UNKNOWN\n    assert pyctuator_impl.get_health().status == Status.UNKNOWN\n\n    health_providers[0].down()\n    assert pyctuator_impl.get_health().status == Status.DOWN\n\n    health_providers[0].up()\n    assert pyctuator_impl.get_health().status == Status.UP\n\n    # first provider is UP, but the second is DOWN\n    health_providers[1].down()\n    assert pyctuator_impl.get_health().status == Status.DOWN\n\n    # first and second providers are UP, 3rd is UNKNOWN\n    health_providers[1].up()\n    assert pyctuator_impl.get_health().status == Status.UP\n"
  },
  {
    "path": "tests/health/test_redis_health_provider.py",
    "content": "# pylint: disable=import-outside-toplevel\n\nimport importlib.util\nimport os\n\nimport pytest\n\n\n@pytest.fixture\ndef require_redis() -> None:\n    if not importlib.util.find_spec(\"redis\"):\n        pytest.skip(\"redis is missing, skipping\")\n\n\n@pytest.mark.usefixtures(\"require_redis\")\n@pytest.fixture\ndef require_redis_server() -> None:\n    should_test_with_redis = os.getenv(\"TEST_REDIS_SERVER\", None)\n    if not should_test_with_redis:\n        pytest.skip(\"No Redis server (env TEST_REDIS_SERVER isn't True), skipping\")\n\n@pytest.fixture\ndef redis_host() -> str:\n    return os.getenv(\"REDIS_HOST\", \"localhost\")\n\n@pytest.mark.usefixtures(\"require_redis\", \"require_redis_server\")\ndef test_redis_health(redis_host: str) -> None:\n    import redis\n    from pyctuator.health.health_provider import Status\n    from pyctuator.health.redis_health_provider import RedisHealthProvider, RedisHealthStatus, RedisHealthDetails\n\n    health = RedisHealthProvider(redis.Redis(host=redis_host)).get_health()\n    assert health == RedisHealthStatus(Status.UP, RedisHealthDetails(\"5.0.3\", \"standalone\"))\n\n\n@pytest.mark.usefixtures(\"require_redis\", \"require_redis_server\")\ndef test_redis_bad_password(redis_host: str) -> None:\n    import redis\n    from pyctuator.health.health_provider import Status\n    from pyctuator.health.redis_health_provider import RedisHealthProvider\n\n    health = RedisHealthProvider(redis.Redis(host=redis_host, password=\"blabla\")).get_health()\n    assert health.status == Status.DOWN\n    assert \"Client sent AUTH, but no password is set\" in str(health.details.failure)\n"
  },
  {
    "path": "tests/httptrace/__init__.py",
    "content": ""
  },
  {
    "path": "tests/httptrace/test_http_header_scrubber.py",
    "content": "import pytest\nfrom pyctuator.httptrace.http_header_scrubber import scrub_header_value\n\n\n@pytest.mark.parametrize(\"key,value\", [\n    (\"Authorization\", \"Bearer 123\"),\n    (\"authorization\", \"Bearer 123\"),\n    (\"X-Csrf-Token\", \"foo\"),\n    (\"authentication\", \"secret123\"),\n    (\"COOKIE\", \"my-logged-in-session\")\n])\ndef test_scrubbing(key: str, value: str) -> None:\n    assert scrub_header_value(key, value) == \"******\"\n\n\n@pytest.mark.parametrize(\"key,value\", [(\"Host\", \"example.org\"), (\"Content-Length\", \"2000\")])\ndef test_non_scrubbing(key: str, value: str) -> None:\n    assert scrub_header_value(key, value) == value\n"
  },
  {
    "path": "tests/httptrace/test_tornado_pyctuator.py",
    "content": "from tornado.httputil import HTTPHeaders\n\nfrom pyctuator.impl.tornado_pyctuator import get_headers\n\n\ndef test_get_headers() -> None:\n    tornado_headers = HTTPHeaders({\"content-type\": \"text/html\"})\n    tornado_headers.add(\"Set-Cookie\", \"A=B\")\n    tornado_headers.add(\"Set-Cookie\", \"C=D\")\n    assert get_headers(tornado_headers) == {\n        \"content-type\": [\"text/html\"],\n        \"set-cookie\": [\"A=B\", \"C=D\"]\n    }\n"
  },
  {
    "path": "tests/logfile/__init__.py",
    "content": ""
  },
  {
    "path": "tests/logfile/test_logfile.py",
    "content": "# pylint: disable=protected-access\nimport logging\n\nfrom pyctuator.logfile.logfile import PyctuatorLogfile  # type: ignore\nfrom pyctuator.pyctuator import default_logfile_format\n\ntest_buffer_size = 1000\n\n\ndef test_empty_response() -> None:\n    logfile = PyctuatorLogfile(test_buffer_size, default_logfile_format)\n    log, start, end = logfile.get_logfile(f\"bytes=-{2 * test_buffer_size}\")\n    assert log == \"\"\n    assert start == 0\n    assert end == 0\n\n\ndef test_buffer_not_full() -> None:\n    logfile = PyctuatorLogfile(test_buffer_size, \"%(message)s\")\n\n    msg_num = \"0123456789\" * 50\n    record = logging.LogRecord(\"test record\", logging.WARNING, \"\", 0, msg_num, (), None)\n    logfile.log_messages.emit(record)\n\n    log, start, end = logfile.get_logfile(f\"bytes=-{2 * test_buffer_size}\")\n    assert start == 0\n    assert end == len(log) == len(msg_num + \"\\n\")\n\n\ndef test_buffer_overflow() -> None:\n    logfile = PyctuatorLogfile(test_buffer_size, \"%(message)s\")\n\n    msg_num = \"0123456789\" * 10\n    record = logging.LogRecord(\"test record\", logging.WARNING, \"\", 0, msg_num, (), None)\n    logfile.log_messages.emit(record)\n\n    msg_chr = \"ABCDEFGHIJ\" * 95\n    record = logging.LogRecord(\"test record\", logging.WARNING, \"\", 0, msg_chr, (), None)\n    logfile.log_messages.emit(record)\n\n    log, start, end = logfile.get_logfile(f\"bytes=-{2 * test_buffer_size}\")\n    assert log.count(\"0123456789\") == 4  # Implicitly Added newlines \"break\" a single string appearance\n    assert start == logfile.get_log_buffer_offset()\n    assert end == start + len(log)\n\n\ndef test_forgotten_records() -> None:\n    logfile = PyctuatorLogfile(test_buffer_size, \"%(message)s\")\n\n    msg_chr = \"ABCDEFGHIJ\"\n    record = logging.LogRecord(\"test record\", logging.WARNING, \"\", 0, msg_chr, (), None)\n    logfile.log_messages.emit(record)\n\n    msg_num = \"0123456789\" * 100  # test_buffer_size\n    record = logging.LogRecord(\"test record\", logging.WARNING, \"\", 0, msg_num, (), None)\n    logfile.log_messages.emit(record)\n\n    log, start, end = logfile.get_logfile(f\"bytes=-{2 * test_buffer_size}\")\n    assert log.count(\"ABCDEFGHIJ\") == 0\n    assert start == logfile.get_log_buffer_offset()\n    assert end == start + len(log)\n"
  },
  {
    "path": "tests/test_disabled_endpoints.py",
    "content": "import logging\nfrom http import HTTPStatus\nfrom typing import Generator\n\nimport pytest\nimport requests\n\nfrom pyctuator.endpoints import Endpoints\nfrom tests.aiohttp_test_server import AiohttpPyctuatorServer\nfrom tests.conftest import RegisteredEndpoints, PyctuatorServer, endpoint_href_path, REQUEST_TIMEOUT\nfrom tests.fast_api_test_server import FastApiPyctuatorServer\nfrom tests.flask_test_server import FlaskPyctuatorServer\nfrom tests.tornado_test_server import TornadoPyctuatorServer\n\n\n@pytest.fixture(\n    params=[\n        Endpoints.THREAD_DUMP | Endpoints.HTTP_TRACE,\n        Endpoints.LOGGERS,\n        Endpoints.ENV,\n        Endpoints.LOGFILE | Endpoints.METRICS | Endpoints.HEALTH,\n    ]\n)\ndef disabled_endpoints(request) -> Generator:  # type: ignore\n    yield request.param\n\n\n@pytest.fixture(\n    params=[FastApiPyctuatorServer, FlaskPyctuatorServer, TornadoPyctuatorServer, AiohttpPyctuatorServer],\n    ids=[\"FastAPI\", \"Flask\", \"Tornado\", \"AIOHTTP\"]\n)\ndef pyctuator_server(disabled_endpoints: Endpoints, request) -> Generator:  # type: ignore\n    # Start the web-server in which the pyctuator is integrated\n    pyctuator_server: PyctuatorServer = request.param(disabled_endpoints)\n    pyctuator_server.start()\n\n    # Yield back to pytest until the module is done\n    yield pyctuator_server\n\n    # Once the module is done, stop the pyctuator-server\n    pyctuator_server.stop()\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_disabled_endpoints_not_shown(\n        disabled_endpoints: Endpoints,\n        registered_endpoints: RegisteredEndpoints,\n) -> None:\n    for endpoint in Endpoints:\n        logging.info(\"Testing that endpoint %s isn't shown in registered endpoints\", endpoint)\n\n        registered_endpoint = {\n            Endpoints.ENV: registered_endpoints.env,\n            Endpoints.INFO: registered_endpoints.info,\n            Endpoints.HEALTH: registered_endpoints.health,\n            Endpoints.METRICS: registered_endpoints.metrics,\n            Endpoints.LOGGERS: registered_endpoints.loggers,\n            Endpoints.THREAD_DUMP: registered_endpoints.threads,\n            Endpoints.LOGFILE: registered_endpoints.logfile,\n            Endpoints.HTTP_TRACE: registered_endpoints.httptrace,\n        }.get(endpoint)\n\n        if endpoint in disabled_endpoints:\n            assert registered_endpoint is None\n        else:\n            assert registered_endpoint is not None\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_disabled_endpoints_not_found(\n        disabled_endpoints: Endpoints,\n        registered_endpoints: RegisteredEndpoints,\n) -> None:\n    for endpoint in Endpoints:\n        if endpoint != Endpoints.NONE and endpoint in disabled_endpoints:\n            endpoint_url = f\"{registered_endpoints.pyctuator}/{endpoint_href_path.get(endpoint)}\"\n            logging.info(\"Testing that disabled-endpoint %s cannot be accessed via %s\", endpoint, endpoint_url)\n            response = requests.get(endpoint_url, timeout=REQUEST_TIMEOUT)\n            assert response.status_code in (HTTPStatus.NOT_FOUND, HTTPStatus.METHOD_NOT_ALLOWED)\n"
  },
  {
    "path": "tests/test_pyctuator_e2e.py",
    "content": "import importlib.util\nimport json\nimport logging\nimport os\nimport random\nimport time\nfrom dataclasses import dataclass, asdict, fields\nfrom datetime import datetime, timedelta\nfrom http import HTTPStatus\nfrom typing import Generator, List\n\nimport pytest\nimport requests\nfrom _pytest.monkeypatch import MonkeyPatch\nfrom requests import Response\n\nfrom pyctuator.impl import SBA_V2_CONTENT_TYPE\nfrom tests.aiohttp_test_server import AiohttpPyctuatorServer\nfrom tests.conftest import RegisteredEndpoints, PyctuatorServer, RegistrationRequest, RegistrationTrackerFixture\nfrom tests.fast_api_test_server import FastApiPyctuatorServer\nfrom tests.flask_test_server import FlaskPyctuatorServer\n# mypy: ignore_errors\nfrom tests.tornado_test_server import TornadoPyctuatorServer\n\nREQUEST_TIMEOUT = 10\n\n\n@pytest.fixture(\n    params=[FastApiPyctuatorServer, FlaskPyctuatorServer, AiohttpPyctuatorServer, TornadoPyctuatorServer],\n    ids=[\"FastAPI\", \"Flask\", \"AIOHTTP\", \"Tornado\"]\n)\ndef pyctuator_server(request) -> Generator:  # type: ignore\n    # Start the web-server in which the pyctuator is integrated\n    pyctuator_server: PyctuatorServer = request.param()\n    pyctuator_server.start()\n\n    # Yield back to pytest until the module is done\n    yield pyctuator_server\n\n    # Once the module is done, stop the pyctuator-server\n    pyctuator_server.stop()\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_response_content_type(\n        registered_endpoints: RegisteredEndpoints,\n        registration_tracker: RegistrationTrackerFixture\n) -> None:\n    # Issue requests to all actuator endpoints and verify the correct content-type is returned\n    actuator_endpoint_names = [field.name for field in fields(RegisteredEndpoints) if field.name != \"root\"]\n    for actuator_endpoint in actuator_endpoint_names:\n        actuator_endpoint_url = asdict(registered_endpoints)[actuator_endpoint]\n        logging.info(\"Testing content type of %s (%s)\", actuator_endpoint, actuator_endpoint_url)\n        response = requests.get(actuator_endpoint_url, timeout=REQUEST_TIMEOUT)\n        assert response.status_code == HTTPStatus.OK\n        assert response.headers.get(\"Content-Type\", response.headers.get(\"content-type\")) == SBA_V2_CONTENT_TYPE\n\n    # Issue requests to non-actuator endpoints and verify the correct content-type is returned\n    assert registration_tracker.registration\n    for non_actuator_endpoint_url in [\"/\", \"/httptrace_test_url\"]:\n        non_actuator_endpoint_url = registration_tracker.registration.serviceUrl[:-1] + non_actuator_endpoint_url\n        response = requests.get(non_actuator_endpoint_url, timeout=REQUEST_TIMEOUT)\n        content_type = response.headers.get(\"Content-Type\", response.headers.get(\"content-type\"))\n        logging.info(\"Testing content type, %s from request %s\", content_type, non_actuator_endpoint_url)\n        assert not content_type or content_type.find(SBA_V2_CONTENT_TYPE) == -1\n\n    # Finally, verify the  content-type headers presented by the httptraces are correct\n    traces = requests.get(registered_endpoints.httptrace, timeout=REQUEST_TIMEOUT).json()[\"traces\"]\n    for trace in traces:\n        request_uri = trace[\"request\"][\"uri\"]\n        response_headers = trace[\"response\"][\"headers\"]\n        content_type_header: List[str] = response_headers.get(\"content-type\", response_headers.get(\"Content-Type\", []))\n\n        logging.info(\"Testing httptraces content-type header for request %s, got %s\", request_uri, content_type_header)\n\n        if request_uri.find(\"/pyctuator\") > 0:\n            assert any(SBA_V2_CONTENT_TYPE in ct for ct in content_type_header)\n        else:\n            assert all(SBA_V2_CONTENT_TYPE not in ct for ct in content_type_header)\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_self_endpoint(registered_endpoints: RegisteredEndpoints) -> None:\n    response = requests.get(registered_endpoints.pyctuator, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == HTTPStatus.OK\n    assert response.json()[\"_links\"] is not None\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_env_endpoint(registered_endpoints: RegisteredEndpoints) -> None:\n    actual_key, actual_value = list(os.environ.items())[3]\n    response = requests.get(registered_endpoints.env, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == HTTPStatus.OK\n    property_sources = response.json()[\"propertySources\"]\n    assert property_sources\n    system_properties = [source for source in property_sources if source[\"name\"] == \"systemEnvironment\"]\n    assert system_properties\n    assert system_properties[0][\"properties\"][actual_key][\"value\"] == actual_value\n\n    response = requests.get(registered_endpoints.info, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == HTTPStatus.OK\n    assert response.json()[\"app\"] is not None\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_info_endpoint(registered_endpoints: RegisteredEndpoints, pyctuator_server: PyctuatorServer) -> None:\n    response = requests.get(registered_endpoints.info, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == HTTPStatus.OK\n    assert response.json()[\"podLinks\"] == pyctuator_server.additional_app_info[\"podLinks\"]\n    assert response.json()[\"serviceLinks\"] == pyctuator_server.additional_app_info[\"serviceLinks\"]\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_health_endpoint_with_psutil(registered_endpoints: RegisteredEndpoints, monkeypatch: MonkeyPatch) -> None:\n    # Skip this test if psutil isn't installed\n    psutil = pytest.importorskip(\"psutil\")\n\n    # Verify that the diskSpace health check is returning some reasonable values\n    response = requests.get(registered_endpoints.health, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == HTTPStatus.OK\n    assert response.json()[\"status\"] == \"UP\"\n    disk_space_health = response.json()[\"details\"][\"diskSpace\"]\n    assert disk_space_health[\"status\"] == \"UP\"\n    assert disk_space_health[\"details\"][\"free\"] > 110000000\n\n    # Now mock the results of psutil so it'll show very small amount of free space\n    @dataclass\n    class MockDiskUsage:\n        total: int\n        free: int\n\n    def mock_disk_usage(path: str) -> MockDiskUsage:\n        # pylint: disable=unused-argument\n        return MockDiskUsage(100000000, 9999999)\n\n    monkeypatch.setattr(psutil, \"disk_usage\", mock_disk_usage)\n    response = requests.get(registered_endpoints.health, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE\n    assert response.json()[\"status\"] == \"DOWN\"\n    disk_space_health = response.json()[\"details\"][\"diskSpace\"]\n    assert disk_space_health[\"status\"] == \"DOWN\"\n    assert disk_space_health[\"details\"][\"free\"] == 9999999\n    assert disk_space_health[\"details\"][\"total\"] == 100000000\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_diskspace_no_psutil(registered_endpoints: RegisteredEndpoints) -> None:\n    # skip if psutil is installed\n    if importlib.util.find_spec(\"psutil\"):\n        pytest.skip(\"psutil installed, skipping\")\n\n    # Verify that the diskSpace health check is returning some reasonable values\n    response = requests.get(registered_endpoints.health, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == HTTPStatus.OK\n    assert response.json()[\"status\"] == \"UP\"\n    assert \"diskSpace\" not in response.json()[\"details\"]\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_metrics_endpoint(registered_endpoints: RegisteredEndpoints) -> None:\n    # Skip this test if psutil isn't installed\n    pytest.importorskip(\"psutil\")\n\n    response = requests.get(registered_endpoints.metrics, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == HTTPStatus.OK\n    metric_names = response.json()[\"names\"]\n    assert \"memory.rss\" in metric_names\n    assert \"thread.count\" in metric_names\n\n    response = requests.get(f\"{registered_endpoints.metrics}/memory.rss\", timeout=REQUEST_TIMEOUT)\n    assert response.status_code == HTTPStatus.OK\n    metric_json = response.json()\n    assert metric_json[\"name\"] == \"memory.rss\"\n    assert metric_json[\"measurements\"][0][\"statistic\"] == \"VALUE\"\n    assert metric_json[\"measurements\"][0][\"value\"] > 10000\n\n    response = requests.get(f\"{registered_endpoints.metrics}/thread.count\", timeout=REQUEST_TIMEOUT)\n    assert response.status_code == HTTPStatus.OK\n    metric_json = response.json()\n    assert metric_json[\"name\"] == \"thread.count\"\n    assert metric_json[\"measurements\"][0][\"statistic\"] == \"COUNT\"\n    assert metric_json[\"measurements\"][0][\"value\"] > 4\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_recurring_registration_and_deregistration(\n        registration_tracker: RegistrationTrackerFixture,\n        pyctuator_server: PyctuatorServer\n) -> None:\n    # Verify that at least 4 registrations occurred within 10 seconds since the test started\n    start = time.time()\n    while registration_tracker.count < 4:\n        time.sleep(0.5)\n        if time.time() - start > 15:\n            pytest.fail(\n                \"Expected at least 4 recurring registrations within 10 seconds but got {registration_tracker.count}\")\n\n    # Verify that the reported startup time is the same across all the registrations and that its later then the test's\n    # start time\n    assert isinstance(registration_tracker.registration, RegistrationRequest)\n    assert registration_tracker.start_time == registration_tracker.registration.metadata[\"startup\"]\n    registration_start_time = datetime.fromisoformat(registration_tracker.start_time)\n    assert registration_start_time > registration_tracker.test_start_time - timedelta(seconds=10)\n\n    # Verify that the randomly generated metadata created when the server starter are included in the registration\n    metadata = registration_tracker.registration.metadata\n    metadata_without_startup = {k: metadata[k] for k in metadata if k != \"startup\"}\n    assert metadata_without_startup == pyctuator_server.metadata\n\n    # Ask to deregister (in real life, called by atexit) and verify it was registered\n    pyctuator_server.atexit()\n    assert registration_tracker.deregistration_time > registration_start_time\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_threads_endpoint(registered_endpoints: RegisteredEndpoints) -> None:\n    response = requests.get(registered_endpoints.threads, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == 200\n\n    threads = response.json()[\"threads\"]\n    assert len(threads) > 4\n\n    main_thread_list = [t for t in threads if t[\"threadName\"] == \"MainThread\"]\n    assert len(main_thread_list) == 1\n\n    stack = main_thread_list[0][\"stackTrace\"]\n    test_stack_entries = [s for s in stack if s[\"fileName\"] == \"test_pyctuator_e2e.py\"]\n    current_test_stack_entry = [t for t in test_stack_entries if t[\"methodName\"] == \"test_threads_endpoint\"]\n    assert len(current_test_stack_entry) == 1\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_loggers_endpoint(registered_endpoints: RegisteredEndpoints) -> None:\n    response = requests.get(registered_endpoints.loggers, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == HTTPStatus.OK\n\n    # levels section\n    loggers_levels = response.json()[\"levels\"]\n    assert \"ERROR\" in loggers_levels\n    assert \"INFO\" in loggers_levels\n    assert \"WARN\" in loggers_levels\n    assert \"DEBUG\" in loggers_levels\n    # logger names section\n    loggers_dict = response.json()[\"loggers\"]\n    assert len(loggers_dict) >= 0\n    for logger in loggers_dict:\n        logger_obj = logging.getLogger(logger)\n        assert hasattr(logger_obj, \"level\")\n        # Individual Get logger route\n        response = requests.get(f\"{registered_endpoints.loggers}/{logger}\", timeout=REQUEST_TIMEOUT)\n        assert response.status_code == HTTPStatus.OK\n        assert \"configuredLevel\" in json.loads(response.content)\n        assert \"effectiveLevel\" in json.loads(response.content)\n        # Set logger level\n        if logger in [\"uvicorn\", ]:  # Skip uvicorn set test, see comment in README.md\n            continue\n\n        current_log_level = json.loads(response.content)[\"configuredLevel\"]\n        other_log_levels = [level for level in loggers_levels if level is not current_log_level]\n        random_log_level = random.choice(other_log_levels)\n        post_data = json.dumps({\"configuredLevel\": str(random_log_level)})\n\n        response = requests.post(\n            f\"{registered_endpoints.loggers}/{logger}\",\n            data=post_data,\n            timeout=REQUEST_TIMEOUT\n        )\n        assert response.status_code == HTTPStatus.OK\n        # Perform get logger level to Validate set logger level succeeded\n        response = requests.get(f\"{registered_endpoints.loggers}/{logger}\", timeout=REQUEST_TIMEOUT)\n        assert json.loads(response.content)[\"configuredLevel\"] == random_log_level\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_logfile_endpoint(registered_endpoints: RegisteredEndpoints) -> None:\n    thirsty_str = \"These pretzels are making me thirsty\"\n    response: Response = requests.get(\n        registered_endpoints.root + \"logfile_test_repeater\",\n        params={\"repeated_string\": thirsty_str},\n        timeout=REQUEST_TIMEOUT,\n    )\n    assert response.status_code == HTTPStatus.OK\n\n    response = requests.get(registered_endpoints.logfile, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == HTTPStatus.OK\n    assert response.text.find(thirsty_str) >= 0\n\n    # Immitate SBA's 1st request\n    response = requests.get(registered_endpoints.logfile, headers={\"Range\": \"bytes=-307200\"}, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == HTTPStatus.PARTIAL_CONTENT\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\", \"pyctuator_server\")\ndef test_traces_endpoint(registered_endpoints: RegisteredEndpoints) -> None:\n    response = requests.get(registered_endpoints.httptrace, timeout=REQUEST_TIMEOUT)\n    assert response.status_code == 200\n\n    # Create a request with header\n    user_header = \"my header test\"\n    authorization = \"bearer 123\"\n    requests.get(\n        registered_endpoints.root + \"httptrace_test_url\",\n        headers={\"User-Data\": user_header, \"authorization\": authorization},\n        timeout=REQUEST_TIMEOUT,\n    )\n\n    # Get the captured httptraces\n    response = requests.get(registered_endpoints.httptrace, timeout=REQUEST_TIMEOUT)\n    response_traces = response.json()[\"traces\"]\n    trace = next(x for x in response_traces if x[\"request\"][\"uri\"].endswith(\"httptrace_test_url\"))\n\n    # Assert header appears on httptrace url\n    assert user_header == trace[\"response\"][\"headers\"][\"resp-data\"][0]\n    assert int(response.headers.get(\"Content-Length\", -1)) > 0\n\n    # Assert Response Secret is scrubbed\n    assert trace[\"response\"][\"headers\"][\"response-secret\"][0] == \"******\"\n\n    # Assert Request Authorization is scrubbed\n    auth_header = \"Authorization\" if \"Authorization\" in trace[\n        \"request\"][\"headers\"] else \"authorization\"\n    assert trace[\"request\"][\"headers\"][auth_header][0] == \"******\"\n\n    # Assert timestamp is formatted in ISO format\n    logging.info(\"Trace's timestamp is %s\", trace[\"timestamp\"])\n    timestamp_truncated = trace[\"timestamp\"][0:23]\n    datetime.fromisoformat(timestamp_truncated)\n\n    # Assert that the \"time taken\" (i.e. the time the server spent processing the request) is less than 100ms\n    assert int(trace[\"timeTaken\"]) < 100\n\n    # Issue the same request asking the server to sleep for a sec, than assert request timing is at least 1s\n    requests.get(\n        registered_endpoints.root + \"httptrace_test_url\",\n        params={\"sleep_sec\": 1},\n        headers={\"User-Data\": user_header},\n        timeout=REQUEST_TIMEOUT\n    )\n    response = requests.get(registered_endpoints.httptrace, timeout=REQUEST_TIMEOUT)\n    response_traces = response.json()[\"traces\"]\n    trace = next(x for x in response_traces if \"httptrace_test_url?sleep_sec\" in x[\"request\"][\"uri\"])\n    assert int(trace[\"timeTaken\"]) >= 1000\n"
  },
  {
    "path": "tests/test_spring_boot_admin_registration.py",
    "content": "import time\nfrom datetime import datetime\nfrom typing import Optional, Any\n\nimport pytest\n\nfrom pyctuator.auth import Auth, BasicAuth\nfrom pyctuator.impl.spring_boot_admin_registration import BootAdminRegistrationHandler\nfrom tests.conftest import RegistrationTrackerFixture\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\")\ndef test_registration_no_auth(registration_tracker: RegistrationTrackerFixture) -> None:\n    registration_handler = get_registration_handler(\"http://localhost:8001/register\", None)\n\n    try:\n        _start_registration(registration_handler)\n        assert registration_tracker.count == 1\n\n    finally:\n        registration_handler.stop()\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\")\ndef test_registration_basic_auth_no_creds(registration_tracker: RegistrationTrackerFixture, caplog: Any) -> None:\n    registration_handler = get_registration_handler(\"http://localhost:8001/register-with-basic-auth\", None)\n\n    try:\n        _start_registration(registration_handler)\n        assert registration_tracker.count == 0\n\n        error_message = \"Failed registering with boot-admin, got %s - %s\"\n        assert error_message in [record.msg for record in caplog.records]\n\n        error_args = (401, b'{\"detail\":\"Not authenticated\"}')\n        assert error_args in [record.args for record in caplog.records if record.msg == error_message]\n\n    finally:\n        registration_handler.stop()\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\")\ndef test_registration_basic_auth_bad_creds(registration_tracker: RegistrationTrackerFixture, caplog: Any) -> None:\n    registration_handler = get_registration_handler(\n        \"http://localhost:8001/register-with-basic-auth\",\n        BasicAuth(\"kuki\", \"puki\")\n    )\n\n    try:\n        _start_registration(registration_handler)\n        assert registration_tracker.count == 0\n\n        error_message = \"Failed registering with boot-admin, got %s - %s\"\n        assert error_message in [record.msg for record in caplog.records]\n\n        error_args = (401, b'{\"detail\":\"Moo haha\"}')\n        assert error_args in [record.args for record in caplog.records if record.msg == error_message]\n\n    finally:\n        registration_handler.stop()\n\n\n@pytest.mark.usefixtures(\"boot_admin_server\")\ndef test_registration_basic_auth(registration_tracker: RegistrationTrackerFixture) -> None:\n    registration_handler = get_registration_handler(\n        \"http://localhost:8001/register-with-basic-auth\",\n        BasicAuth(\"moo\", \"haha\")\n    )\n\n    try:\n        _start_registration(registration_handler)\n        assert registration_tracker.count == 1\n\n    finally:\n        registration_handler.stop()\n\n\ndef get_registration_handler(registration_url: str, registration_auth: Optional[Auth]) -> BootAdminRegistrationHandler:\n    return BootAdminRegistrationHandler(\n        registration_url=registration_url,\n        registration_auth=registration_auth,\n        application_name=\"noauth\",\n        pyctuator_base_url=\"http://whatever/pyctuator\",\n        start_time=datetime.now(),\n        service_url=\"http://whatever/service\",\n        registration_interval_sec=100\n    )\n\n\ndef _start_registration(registration_handler: BootAdminRegistrationHandler) -> None:\n    # Registration is done asynchronously, for the test, ask to register shortly after start is called\n    registration_handler.start(0.01)\n\n    # Wait enough after starting the registration allowing the async registration to happen.\n    time.sleep(0.1)\n"
  },
  {
    "path": "tests/tornado_test_server.py",
    "content": "import logging\nimport threading\nimport time\nfrom typing import Optional\n\nfrom tornado import ioloop\nfrom tornado.httpserver import HTTPServer\nfrom tornado.web import Application, RequestHandler\n\nfrom pyctuator.endpoints import Endpoints\nfrom pyctuator.pyctuator import Pyctuator\nfrom tests.conftest import PyctuatorServer\n\nbind_port = 9000\n\n\nclass TornadoPyctuatorServer(PyctuatorServer):\n    def __init__(self, disabled_endpoints: Endpoints = Endpoints.NONE) -> None:\n        global bind_port\n        self.port = bind_port\n        bind_port += 1\n\n        # pylint: disable=abstract-method\n        class LogfileTestRepeater(RequestHandler):\n            def get(self) -> None:\n                repeated_string = self.get_argument(\"repeated_string\")\n                logging.error(repeated_string)\n                self.write(repeated_string)\n\n        # pylint: disable=abstract-method\n        class GetHttptraceTestUrl(RequestHandler):\n            def get(self) -> None:\n                sleep_sec: Optional[str] = self.get_argument(\"sleep_sec\", None)\n                # Sleep if requested to sleep - used for asserting httptraces timing\n                if sleep_sec:\n                    logging.info(\n                        \"Sleeping %s seconds before replying\", sleep_sec)\n                    time.sleep(int(sleep_sec))\n\n                # Echo 'User-Data' header as 'resp-data' - used for asserting headers are captured properly\n                self.add_header(\n                    \"resp-data\", str(self.request.headers.get(\"User-Data\")))\n                self.add_header(\n                    \"response-secret\", \"my password\")\n                self.write(\"my content\")\n\n        self.app = Application(\n            [\n                (\"/logfile_test_repeater\", LogfileTestRepeater),\n                (\"/httptrace_test_url\", GetHttptraceTestUrl)\n            ],\n            debug=False\n        )\n\n        self.pyctuator = Pyctuator(\n            self.app,\n            \"Tornado Pyctuator\",\n            app_url=f\"http://localhost:{self.port}\",\n            pyctuator_endpoint_url=f\"http://localhost:{self.port}/pyctuator\",\n            registration_url=\"http://localhost:8001/register\",\n            app_description=\"Demonstrate Spring Boot Admin Integration with Tornado\",\n            registration_interval_sec=1,\n            metadata=self.metadata,\n            additional_app_info=self.additional_app_info,\n            disabled_endpoints=disabled_endpoints,\n        )\n\n        self.io_loop: Optional[ioloop.IOLoop] = None\n        self.http_server = HTTPServer(self.app, decompress_request=True)\n        self.thread = threading.Thread(target=self._start_in_thread)\n\n    def _start_in_thread(self) -> None:\n        self.io_loop = ioloop.IOLoop()\n        self.app.listen(self.port)\n        self.io_loop.start()\n\n    def start(self) -> None:\n        logging.info(\"Starting Tornado server\")\n        self.thread.start()\n        time.sleep(0.5)\n\n    def stop(self) -> None:\n        logging.info(\"Stopping Tornado server\")\n        self.pyctuator.stop()\n\n        # Allow the recurring registration to complete any in-progress request before stopping Tornado\n        time.sleep(1)\n\n        assert self.io_loop is not None\n\n        self.http_server.stop()\n        self.io_loop.add_callback(self.io_loop.stop)\n        self.thread.join()\n        self.io_loop.close(all_fds=True)\n\n    def atexit(self) -> None:\n        if self.pyctuator.boot_admin_registration_handler:\n            self.pyctuator.boot_admin_registration_handler.deregister_from_admin_server()\n"
  }
]