diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d54c593 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +* text eol=lf diff --git a/.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md b/.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md new file mode 100644 index 0000000..8de294e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/adafruit_circuitpython_pr.md @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2021 Adafruit Industries +# +# SPDX-License-Identifier: MIT + +Thank you for contributing! Before you submit a pull request, please read the following. + +Make sure any changes you're submitting are in line with the CircuitPython Design Guide, available here: https://docs.circuitpython.org/en/latest/docs/design_guide.html + +If your changes are to documentation, please verify that the documentation builds locally by following the steps found here: https://adafru.it/build-docs + +Before submitting the pull request, make sure you've run Pylint and Black locally on your code. You can do this manually or using pre-commit. Instructions are available here: https://adafru.it/check-your-code + +Please remove all of this text before submitting. Include an explanation or list of changes included in your PR, as well as, if applicable, a link to any related issues. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b6977a9..041a337 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries +# +# SPDX-License-Identifier: MIT + name: Build CI on: [pull_request, push] @@ -6,52 +10,5 @@ jobs: test: runs-on: ubuntu-latest steps: - - name: Dump GitHub context - env: - GITHUB_CONTEXT: ${{ toJson(github) }} - run: echo "$GITHUB_CONTEXT" - - name: Translate Repo Name For Build Tools filename_prefix - id: repo-name - run: | - echo ::set-output name=repo-name::$( - echo ${{ github.repository }} | - awk -F '\/' '{ print tolower($2) }' | - tr '_' '-' - ) - - name: Set up Python 3.6 - uses: actions/setup-python@v1 - with: - python-version: 3.6 - - name: Versions - run: | - python3 --version - - name: Checkout Current Repo - uses: actions/checkout@v1 - with: - submodules: true - - name: Checkout tools repo - uses: actions/checkout@v2 - with: - repository: adafruit/actions-ci-circuitpython-libs - path: actions-ci - - name: Install dependencies - # (e.g. - apt-get: gettext, etc; pip: circuitpython-build-tools, requirements.txt; etc.) - run: | - source actions-ci/install.sh - - name: Pip install pylint, black, & Sphinx - run: | - pip install --force-reinstall pylint black==19.10b0 Sphinx sphinx-rtd-theme - - name: Library version - run: git describe --dirty --always --tags - - name: Check formatting - run: | - black --check --target-version=py35 . - - name: PyLint - run: | - pylint $( find . -path './adafruit*.py' ) - ([[ ! -d "examples" ]] || pylint --disable=missing-docstring,invalid-name,bad-whitespace $( find . -path "./examples/*.py" )) - - name: Build assets - run: circuitpython-build-bundles --filename_prefix ${{ steps.repo-name.outputs.repo-name }} --library_location . - - name: Build docs - working-directory: docs - run: sphinx-build -E -W -b html . _build/html + - name: Run Build CI workflow + uses: adafruit/workflows-circuitpython-libs/build@main diff --git a/.github/workflows/failure-help-text.yml b/.github/workflows/failure-help-text.yml new file mode 100644 index 0000000..0b1194f --- /dev/null +++ b/.github/workflows/failure-help-text.yml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2021 Scott Shawcroft for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +name: Failure help text + +on: + workflow_run: + workflows: ["Build CI"] + types: + - completed + +jobs: + post-help: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event == 'pull_request' }} + steps: + - name: Post comment to help + uses: adafruit/circuitpython-action-library-ci-failed@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 18efb9c..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Release Actions - -on: - release: - types: [published] - -jobs: - upload-release-assets: - runs-on: ubuntu-latest - steps: - - name: Dump GitHub context - env: - GITHUB_CONTEXT: ${{ toJson(github) }} - run: echo "$GITHUB_CONTEXT" - - name: Translate Repo Name For Build Tools filename_prefix - id: repo-name - run: | - echo ::set-output name=repo-name::$( - echo ${{ github.repository }} | - awk -F '\/' '{ print tolower($2) }' | - tr '_' '-' - ) - - name: Set up Python 3.6 - uses: actions/setup-python@v1 - with: - python-version: 3.6 - - name: Versions - run: | - python3 --version - - name: Checkout Current Repo - uses: actions/checkout@v1 - with: - submodules: true - - name: Checkout tools repo - uses: actions/checkout@v2 - with: - repository: adafruit/actions-ci-circuitpython-libs - path: actions-ci - - name: Install deps - run: | - source actions-ci/install.sh - - name: Build assets - run: circuitpython-build-bundles --filename_prefix ${{ steps.repo-name.outputs.repo-name }} --library_location . - - name: Upload Release Assets - # the 'official' actions version does not yet support dynamically - # supplying asset names to upload. @csexton's version chosen based on - # discussion in the issue below, as its the simplest to implement and - # allows for selecting files with a pattern. - # https://github.com/actions/upload-release-asset/issues/4 - #uses: actions/upload-release-asset@v1.0.1 - uses: csexton/release-asset-action@master - with: - pattern: "bundles/*" - github-token: ${{ secrets.GITHUB_TOKEN }} - - upload-pypi: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - name: Check For setup.py - id: need-pypi - run: | - echo ::set-output name=setup-py::$( find . -wholename './setup.py' ) - - name: Set up Python - if: contains(steps.need-pypi.outputs.setup-py, 'setup.py') - uses: actions/setup-python@v1 - with: - python-version: '3.x' - - name: Install dependencies - if: contains(steps.need-pypi.outputs.setup-py, 'setup.py') - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build and publish - if: contains(steps.need-pypi.outputs.setup-py, 'setup.py') - env: - TWINE_USERNAME: ${{ secrets.pypi_username }} - TWINE_PASSWORD: ${{ secrets.pypi_password }} - run: | - python setup.py sdist - twine upload dist/* diff --git a/.github/workflows/release_gh.yml b/.github/workflows/release_gh.yml new file mode 100644 index 0000000..9acec60 --- /dev/null +++ b/.github/workflows/release_gh.yml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +name: GitHub Release Actions + +on: + release: + types: [published] + +jobs: + upload-release-assets: + runs-on: ubuntu-latest + steps: + - name: Run GitHub Release CI workflow + uses: adafruit/workflows-circuitpython-libs/release-gh@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + upload-url: ${{ github.event.release.upload_url }} diff --git a/.github/workflows/release_pypi.yml b/.github/workflows/release_pypi.yml new file mode 100644 index 0000000..65775b7 --- /dev/null +++ b/.github/workflows/release_pypi.yml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +name: PyPI Release Actions + +on: + release: + types: [published] + +jobs: + upload-release-assets: + runs-on: ubuntu-latest + steps: + - name: Run PyPI Release CI workflow + uses: adafruit/workflows-circuitpython-libs/release-pypi@main + with: + pypi-username: ${{ secrets.pypi_username }} + pypi-password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 2f9163b..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Run Tests - -on: [pull_request, push] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Set up Python 3.6 - uses: actions/setup-python@v1 - with: - python-version: 3.6 - - name: Versions - run: | - python3 --version - - name: Checkout Current Repo - uses: actions/checkout@v1 - with: - submodules: true - - name: Install pytest - run: pip install pytest - - name: Install locally - run: pip install . - - name: Run tests - run: pytest diff --git a/.gitignore b/.gitignore index c83f8b7..a06dc67 100755 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,55 @@ +# SPDX-FileCopyrightText: 2022 Kattni Rembor, written for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +# Do not include files and directories created by your personal work environment, such as the IDE +# you use, except for those already listed here. Pull requests including changes to this file will +# not be accepted. + +# This .gitignore file contains rules for files generated by working with CircuitPython libraries, +# including building Sphinx, testing with pip, and creating a virual environment, as well as the +# MacOS and IDE-specific files generated by using MacOS in general, or the PyCharm or VSCode IDEs. + +# If you find that there are files being generated on your machine that should not be included in +# your git commit, you should create a .gitignore_global file on your computer to include the +# files created by your personal setup. To do so, follow the two steps below. + +# First, create a file called .gitignore_global somewhere convenient for you, and add rules for +# the files you want to exclude from git commits. + +# Second, configure Git to use the exclude file for all Git repositories by running the +# following via commandline, replacing "path/to/your/" with the actual path to your newly created +# .gitignore_global file: +# git config --global core.excludesfile path/to/your/.gitignore_global + +# CircuitPython-specific files *.mpy -.idea + +# Python-specific files __pycache__ -_build *.pyc + +# Sphinx build-specific files +_build + +# This file results from running `pip -e install .` in a local repository +*.egg-info + +# Virtual environment-specific files .env -bundles +.venv + +# MacOS-specific files *.DS_Store -.eggs -dist -**/*.egg-info \ No newline at end of file + +# IDE-specific files +.idea +.vscode +*~ + +# tox-specific files +.tox +build + +# coverage-specific files +.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f27b786 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2020 Diego Elio Pettenò +# SPDX-FileCopyrightText: 2024 Justin Myers +# +# SPDX-License-Identifier: Unlicense + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.4 + hooks: + - id: ruff-format + - id: ruff + args: ["--fix"] + - repo: https://github.com/fsfe/reuse-tool + rev: v3.0.1 + hooks: + - id: reuse diff --git a/.pylintrc b/.pylintrc deleted file mode 100755 index d8f0ee8..0000000 --- a/.pylintrc +++ /dev/null @@ -1,433 +0,0 @@ -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code -extension-pkg-whitelist= - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. -# jobs=1 -jobs=2 - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# Specify a configuration file. -#rcfile= - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use"--disable=all --enable=classes -# --disable=W" -# disable=import-error,print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call -disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,import-error,bad-continuation - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable= - - -[REPORTS] - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio).You can also give a reporter class, eg -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - - -[LOGGING] - -# Logging modules to check that the string format arguments are in logging -# function parameter format -logging-modules=logging - - -[SPELLING] - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -# notes=FIXME,XXX,TODO -notes=FIXME,XXX - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules=board - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_,_cb - -# A regular expression matching the name of dummy variables (i.e. expectedly -# not used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,future.builtins - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -# expected-line-ending-format= -expected-line-ending-format=LF - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=100 - -# Maximum number of lines in a module -max-module-lines=1000 - -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma,dict-separator - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[BASIC] - -# Naming hint for argument names -argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct argument names -argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Naming hint for attribute names -attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct attribute names -attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata - -# Naming hint for class attribute names -class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Regular expression matching correct class attribute names -class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ - -# Naming hint for class names -# class-name-hint=[A-Z_][a-zA-Z0-9]+$ -class-name-hint=[A-Z_][a-zA-Z0-9_]+$ - -# Regular expression matching correct class names -# class-rgx=[A-Z_][a-zA-Z0-9]+$ -class-rgx=[A-Z_][a-zA-Z0-9_]+$ - -# Naming hint for constant names -const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Regular expression matching correct constant names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming hint for function names -function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct function names -function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Good variable names which should always be accepted, separated by a comma -# good-names=i,j,k,ex,Run,_ -good-names=r,g,b,w,i,j,k,n,x,y,z,ex,ok,Run,_ - -# Include a hint for the correct naming format with invalid-name -include-naming-hint=no - -# Naming hint for inline iteration names -inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ - -# Regular expression matching correct inline iteration names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ - -# Naming hint for method names -method-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct method names -method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Naming hint for module names -module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Regular expression matching correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -property-classes=abc.abstractproperty - -# Naming hint for variable names -variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - -# Regular expression matching correct variable names -variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ - - -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs - - -[DESIGN] - -# Maximum number of arguments for function / method -max-args=5 - -# Maximum number of attributes for a class (see R0902). -# max-attributes=7 -max-attributes=11 - -# Maximum number of boolean expressions in a if statement -max-bool-expr=5 - -# Maximum number of branch for function / method body -max-branches=12 - -# Maximum number of locals for function / method body -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body -max-returns=6 - -# Maximum number of statements in function / method body -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=1 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..255dafd --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +sphinx: + configuration: docs/conf.py + +build: + os: ubuntu-lts-latest + tools: + python: "3" + +python: + install: + - requirements: docs/requirements.txt + - requirements: requirements.txt diff --git a/.readthedocs.yml b/.readthedocs.yml deleted file mode 100755 index f4243ad..0000000 --- a/.readthedocs.yml +++ /dev/null @@ -1,3 +0,0 @@ -python: - version: 3 -requirements_file: requirements.txt diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 134d510..8a55c07 100755 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,3 +1,9 @@ + + # Adafruit Community Code of Conduct ## Our Pledge @@ -43,7 +49,7 @@ Examples of unacceptable behavior by participants include: The goal of the standards and moderation guidelines outlined here is to build and maintain a respectful community. We ask that you don’t just aim to be -"technically unimpeachable", but rather try to be your best self. +"technically unimpeachable", but rather try to be your best self. We value many things beyond technical expertise, including collaboration and supporting others within our community. Providing a positive experience for @@ -74,9 +80,9 @@ You may report in the following ways: In any situation, you may send an email to . On the Adafruit Discord, you may send an open message from any channel -to all Community Moderators by tagging @community moderators. You may -also send an open message from any channel, or a direct message to -@kattni#1507, @tannewt#4653, @Dan Halbert#1614, @cater#2442, +to all Community Moderators by tagging @community moderators. You may +also send an open message from any channel, or a direct message to +@kattni#1507, @tannewt#4653, @Dan Halbert#1614, @cater#2442, @sommersoft#0222, @Mr. Certainly#0472 or @Andon#8175. Email and direct message reports will be kept confidential. diff --git a/LICENSE b/LICENSE old mode 100755 new mode 100644 diff --git a/LICENSES/CC-BY-4.0.txt b/LICENSES/CC-BY-4.0.txt new file mode 100644 index 0000000..3f92dfc --- /dev/null +++ b/LICENSES/CC-BY-4.0.txt @@ -0,0 +1,324 @@ +Creative Commons Attribution 4.0 International Creative Commons Corporation +("Creative Commons") is not a law firm and does not provide legal services +or legal advice. Distribution of Creative Commons public licenses does not +create a lawyer-client or other relationship. Creative Commons makes its licenses +and related information available on an "as-is" basis. Creative Commons gives +no warranties regarding its licenses, any material licensed under their terms +and conditions, or any related information. Creative Commons disclaims all +liability for damages resulting from their use to the fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and conditions +that creators and other rights holders may use to share original works of +authorship and other material subject to copyright and certain other rights +specified in the public license below. The following considerations are for +informational purposes only, are not exhaustive, and do not form part of our +licenses. + +Considerations for licensors: Our public licenses are intended for use by +those authorized to give the public permission to use material in ways otherwise +restricted by copyright and certain other rights. Our licenses are irrevocable. +Licensors should read and understand the terms and conditions of the license +they choose before applying it. Licensors should also secure all rights necessary +before applying our licenses so that the public can reuse the material as +expected. Licensors should clearly mark any material not subject to the license. +This includes other CC-licensed material, or material used under an exception +or limitation to copyright. More considerations for licensors : wiki.creativecommons.org/Considerations_for_licensors + +Considerations for the public: By using one of our public licenses, a licensor +grants the public permission to use the licensed material under specified +terms and conditions. If the licensor's permission is not necessary for any +reason–for example, because of any applicable exception or limitation to copyright–then +that use is not regulated by the license. Our licenses grant only permissions +under copyright and certain other rights that a licensor has authority to +grant. Use of the licensed material may still be restricted for other reasons, +including because others have copyright or other rights in the material. A +licensor may make special requests, such as asking that all changes be marked +or described. Although not required by our licenses, you are encouraged to +respect those requests where reasonable. More considerations for the public +: wiki.creativecommons.org/Considerations_for_licensees Creative Commons Attribution +4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree to +be bound by the terms and conditions of this Creative Commons Attribution +4.0 International Public License ("Public License"). To the extent this Public +License may be interpreted as a contract, You are granted the Licensed Rights +in consideration of Your acceptance of these terms and conditions, and the +Licensor grants You such rights in consideration of benefits the Licensor +receives from making the Licensed Material available under these terms and +conditions. + +Section 1 – Definitions. + +a. Adapted Material means material subject to Copyright and Similar Rights +that is derived from or based upon the Licensed Material and in which the +Licensed Material is translated, altered, arranged, transformed, or otherwise +modified in a manner requiring permission under the Copyright and Similar +Rights held by the Licensor. For purposes of this Public License, where the +Licensed Material is a musical work, performance, or sound recording, Adapted +Material is always produced where the Licensed Material is synched in timed +relation with a moving image. + +b. Adapter's License means the license You apply to Your Copyright and Similar +Rights in Your contributions to Adapted Material in accordance with the terms +and conditions of this Public License. + +c. Copyright and Similar Rights means copyright and/or similar rights closely +related to copyright including, without limitation, performance, broadcast, +sound recording, and Sui Generis Database Rights, without regard to how the +rights are labeled or categorized. For purposes of this Public License, the +rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. + +d. Effective Technological Measures means those measures that, in the absence +of proper authority, may not be circumvented under laws fulfilling obligations +under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, +and/or similar international agreements. + +e. Exceptions and Limitations means fair use, fair dealing, and/or any other +exception or limitation to Copyright and Similar Rights that applies to Your +use of the Licensed Material. + +f. Licensed Material means the artistic or literary work, database, or other +material to which the Licensor applied this Public License. + +g. Licensed Rights means the rights granted to You subject to the terms and +conditions of this Public License, which are limited to all Copyright and +Similar Rights that apply to Your use of the Licensed Material and that the +Licensor has authority to license. + +h. Licensor means the individual(s) or entity(ies) granting rights under this +Public License. + +i. Share means to provide material to the public by any means or process that +requires permission under the Licensed Rights, such as reproduction, public +display, public performance, distribution, dissemination, communication, or +importation, and to make material available to the public including in ways +that members of the public may access the material from a place and at a time +individually chosen by them. + +j. Sui Generis Database Rights means rights other than copyright resulting +from Directive 96/9/EC of the European Parliament and of the Council of 11 +March 1996 on the legal protection of databases, as amended and/or succeeded, +as well as other essentially equivalent rights anywhere in the world. + +k. You means the individual or entity exercising the Licensed Rights under +this Public License. Your has a corresponding meaning. + +Section 2 – Scope. + + a. License grant. + +1. Subject to the terms and conditions of this Public License, the Licensor +hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, +irrevocable license to exercise the Licensed Rights in the Licensed Material +to: + + A. reproduce and Share the Licensed Material, in whole or in part; and + + B. produce, reproduce, and Share Adapted Material. + +2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions +and Limitations apply to Your use, this Public License does not apply, and +You do not need to comply with its terms and conditions. + + 3. Term. The term of this Public License is specified in Section 6(a). + +4. Media and formats; technical modifications allowed. The Licensor authorizes +You to exercise the Licensed Rights in all media and formats whether now known +or hereafter created, and to make technical modifications necessary to do +so. The Licensor waives and/or agrees not to assert any right or authority +to forbid You from making technical modifications necessary to exercise the +Licensed Rights, including technical modifications necessary to circumvent +Effective Technological Measures. For purposes of this Public License, simply +making modifications authorized by this Section 2(a)(4) never produces Adapted +Material. + + 5. Downstream recipients. + +A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed +Material automatically receives an offer from the Licensor to exercise the +Licensed Rights under the terms and conditions of this Public License. + +B. No downstream restrictions. You may not offer or impose any additional +or different terms or conditions on, or apply any Effective Technological +Measures to, the Licensed Material if doing so restricts exercise of the Licensed +Rights by any recipient of the Licensed Material. + +6. No endorsement. Nothing in this Public License constitutes or may be construed +as permission to assert or imply that You are, or that Your use of the Licensed +Material is, connected with, or sponsored, endorsed, or granted official status +by, the Licensor or others designated to receive attribution as provided in +Section 3(a)(1)(A)(i). + + b. Other rights. + +1. Moral rights, such as the right of integrity, are not licensed under this +Public License, nor are publicity, privacy, and/or other similar personality +rights; however, to the extent possible, the Licensor waives and/or agrees +not to assert any such rights held by the Licensor to the limited extent necessary +to allow You to exercise the Licensed Rights, but not otherwise. + +2. Patent and trademark rights are not licensed under this Public License. + +3. To the extent possible, the Licensor waives any right to collect royalties +from You for the exercise of the Licensed Rights, whether directly or through +a collecting society under any voluntary or waivable statutory or compulsory +licensing scheme. In all other cases the Licensor expressly reserves any right +to collect such royalties. + +Section 3 – License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the following +conditions. + + a. Attribution. + +1. If You Share the Licensed Material (including in modified form), You must: + +A. retain the following if it is supplied by the Licensor with the Licensed +Material: + +i. identification of the creator(s) of the Licensed Material and any others +designated to receive attribution, in any reasonable manner requested by the +Licensor (including by pseudonym if designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of warranties; + +v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; + +B. indicate if You modified the Licensed Material and retain an indication +of any previous modifications; and + +C. indicate the Licensed Material is licensed under this Public License, and +include the text of, or the URI or hyperlink to, this Public License. + +2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner +based on the medium, means, and context in which You Share the Licensed Material. +For example, it may be reasonable to satisfy the conditions by providing a +URI or hyperlink to a resource that includes the required information. + +3. If requested by the Licensor, You must remove any of the information required +by Section 3(a)(1)(A) to the extent reasonably practicable. + +4. If You Share Adapted Material You produce, the Adapter's License You apply +must not prevent recipients of the Adapted Material from complying with this +Public License. + +Section 4 – Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that apply to +Your use of the Licensed Material: + +a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, +reuse, reproduce, and Share all or a substantial portion of the contents of +the database; + +b. if You include all or a substantial portion of the database contents in +a database in which You have Sui Generis Database Rights, then the database +in which You have Sui Generis Database Rights (but not its individual contents) +is Adapted Material; and + +c. You must comply with the conditions in Section 3(a) if You Share all or +a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not replace +Your obligations under this Public License where the Licensed Rights include +other Copyright and Similar Rights. + +Section 5 – Disclaimer of Warranties and Limitation of Liability. + +a. Unless otherwise separately undertaken by the Licensor, to the extent possible, +the Licensor offers the Licensed Material as-is and as-available, and makes +no representations or warranties of any kind concerning the Licensed Material, +whether express, implied, statutory, or other. This includes, without limitation, +warranties of title, merchantability, fitness for a particular purpose, non-infringement, +absence of latent or other defects, accuracy, or the presence or absence of +errors, whether or not known or discoverable. Where disclaimers of warranties +are not allowed in full or in part, this disclaimer may not apply to You. + +b. To the extent possible, in no event will the Licensor be liable to You +on any legal theory (including, without limitation, negligence) or otherwise +for any direct, special, indirect, incidental, consequential, punitive, exemplary, +or other losses, costs, expenses, or damages arising out of this Public License +or use of the Licensed Material, even if the Licensor has been advised of +the possibility of such losses, costs, expenses, or damages. Where a limitation +of liability is not allowed in full or in part, this limitation may not apply +to You. + +c. The disclaimer of warranties and limitation of liability provided above +shall be interpreted in a manner that, to the extent possible, most closely +approximates an absolute disclaimer and waiver of all liability. + +Section 6 – Term and Termination. + +a. This Public License applies for the term of the Copyright and Similar Rights +licensed here. However, if You fail to comply with this Public License, then +Your rights under this Public License terminate automatically. + +b. Where Your right to use the Licensed Material has terminated under Section +6(a), it reinstates: + +1. automatically as of the date the violation is cured, provided it is cured +within 30 days of Your discovery of the violation; or + + 2. upon express reinstatement by the Licensor. + +c. For the avoidance of doubt, this Section 6(b) does not affect any right +the Licensor may have to seek remedies for Your violations of this Public +License. + +d. For the avoidance of doubt, the Licensor may also offer the Licensed Material +under separate terms or conditions or stop distributing the Licensed Material +at any time; however, doing so will not terminate this Public License. + + e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. + +Section 7 – Other Terms and Conditions. + +a. The Licensor shall not be bound by any additional or different terms or +conditions communicated by You unless expressly agreed. + +b. Any arrangements, understandings, or agreements regarding the Licensed +Material not stated herein are separate from and independent of the terms +and conditions of this Public License. + +Section 8 – Interpretation. + +a. For the avoidance of doubt, this Public License does not, and shall not +be interpreted to, reduce, limit, restrict, or impose conditions on any use +of the Licensed Material that could lawfully be made without permission under +this Public License. + +b. To the extent possible, if any provision of this Public License is deemed +unenforceable, it shall be automatically reformed to the minimum extent necessary +to make it enforceable. If the provision cannot be reformed, it shall be severed +from this Public License without affecting the enforceability of the remaining +terms and conditions. + +c. No term or condition of this Public License will be waived and no failure +to comply consented to unless expressly agreed to by the Licensor. + +d. Nothing in this Public License constitutes or may be interpreted as a limitation +upon, or waiver of, any privileges and immunities that apply to the Licensor +or You, including from the legal processes of any jurisdiction or authority. + +Creative Commons is not a party to its public licenses. Notwithstanding, Creative +Commons may elect to apply one of its public licenses to material it publishes +and in those instances will be considered the "Licensor." The text of the +Creative Commons public licenses is dedicated to the public domain under the +CC0 Public Domain Dedication. Except for the limited purpose of indicating +that material is shared under a Creative Commons public license or as otherwise +permitted by the Creative Commons policies published at creativecommons.org/policies, +Creative Commons does not authorize the use of the trademark "Creative Commons" +or any other trademark or logo of Creative Commons without its prior written +consent including, without limitation, in connection with any unauthorized +modifications to any of its public licenses or any other arrangements, understandings, +or agreements concerning use of licensed material. For the avoidance of doubt, +this paragraph does not form part of the public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..204b93d --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,19 @@ +MIT License Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSES/Unlicense.txt b/LICENSES/Unlicense.txt new file mode 100644 index 0000000..24a8f90 --- /dev/null +++ b/LICENSES/Unlicense.txt @@ -0,0 +1,20 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or distribute +this software, either in source code form or as a compiled binary, for any +purpose, commercial or non-commercial, and by any means. + +In jurisdictions that recognize copyright laws, the author or authors of this +software dedicate any and all copyright interest in the software to the public +domain. We make this dedication for the benefit of the public at large and +to the detriment of our heirs and successors. We intend this dedication to +be an overt act of relinquishment in perpetuity of all present and future +rights to this software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, +please refer to diff --git a/README.rst b/README.rst index 51a5ec0..4e73438 100755 --- a/README.rst +++ b/README.rst @@ -2,10 +2,10 @@ Introduction ============ .. image:: https://readthedocs.org/projects/adafruit-circuitpython-requests/badge/?version=latest - :target: https://circuitpython.readthedocs.io/projects/requests/en/latest/ + :target: https://docs.circuitpython.org/projects/requests/en/latest/ :alt: Documentation Status -.. image:: https://img.shields.io/discord/327254708534116352.svg +.. image:: https://raw.githubusercontent.com/adafruit/Adafruit_CircuitPython_Bundle/main/badges/adafruit_discord.svg :target: https://adafru.it/discord :alt: Discord @@ -13,6 +13,10 @@ Introduction :target: https://github.com/adafruit/Adafruit_CircuitPython_Requests/actions/ :alt: Build Status +.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json + :target: https://github.com/astral-sh/ruff + :alt: Code Style: Ruff + A requests-like library for HTTP commands. @@ -21,6 +25,7 @@ Dependencies This driver depends on: * `Adafruit CircuitPython `_ +* `Adafruit CircuitPython ConnectionManager `_ Please ensure all dependencies are available on the CircuitPython filesystem. This is easily achieved by downloading @@ -46,8 +51,8 @@ To install in a virtual environment in your current project: .. code-block:: shell mkdir project-name && cd project-name - python3 -m venv .env - source .env/bin/activate + python3 -m venv .venv + source .venv/bin/activate pip3 install adafruit-circuitpython-requests Usage Example @@ -55,14 +60,16 @@ Usage Example Usage examples are within the `examples` subfolder of this library. +Documentation +============= + +API documentation for this library can be found on `Read the Docs `_. + +For information on building library documentation, please check out `this guide `_. + Contributing ============ Contributions are welcome! Please read our `Code of Conduct -`_ +`_ before contributing to help this project stay welcoming. - -Documentation -============= - -For information on building library documentation, please check out `this guide `_. diff --git a/README.rst.license b/README.rst.license new file mode 100644 index 0000000..11cd75d --- /dev/null +++ b/README.rst.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries + +SPDX-License-Identifier: MIT diff --git a/adafruit_requests.py b/adafruit_requests.py index c09b17f..d15db69 100644 --- a/adafruit_requests.py +++ b/adafruit_requests.py @@ -1,25 +1,8 @@ -# The MIT License (MIT) +# SPDX-FileCopyrightText: 2019 ladyada for Adafruit Industries +# SPDX-FileCopyrightText: 2020 Scott Shawcroft for Adafruit Industries # -# Copyright (c) 2019 ladyada for Adafruit Industries -# Copyright (c) 2020 Scott Shawcroft for Adafruit Industries -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# SPDX-License-Identifier: MIT + """ `adafruit_requests` ================================================================================ @@ -48,28 +31,39 @@ * Adafruit CircuitPython firmware for the supported boards: https://github.com/adafruit/circuitpython/releases +* Adafruit's Connection Manager library: + https://github.com/adafruit/Adafruit_CircuitPython_ConnectionManager + """ -__version__ = "0.0.0-auto.0" +__version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_Requests.git" import errno +import json as json_module +import os +import sys + +from adafruit_connection_manager import get_connection_manager -# CircuitPython 6.0 does not have the bytearray.split method. -# This function emulates buf.split(needle)[0], which is the functionality -# required. -def _buffer_split0(buf, needle): - index = buf.find(needle) - if index == -1: - return buf - return buf[:index] +SEEK_END = 2 + +if not sys.implementation.name == "circuitpython": + from types import TracebackType + from typing import IO, Any, Dict, Optional, Type + + from circuitpython_typing.socket import ( + SocketpoolModuleType, + SocketType, + SSLContextType, + ) class _RawResponse: - def __init__(self, response): + def __init__(self, response: "Response") -> None: self._response = response - def read(self, size=-1): + def read(self, size: int = -1) -> bytes: """Read as much as available or up to size and return it in a byte string. Do NOT use this unless you really need to. Reusing memory with `readinto` is much better. @@ -78,14 +72,10 @@ def read(self, size=-1): return self._response.content return self._response.socket.recv(size) - def readinto(self, buf): + def readinto(self, buf: bytearray) -> int: """Read as much as available into buf or until it is full. Returns the number of bytes read into buf.""" - return self._response._readinto(buf) # pylint: disable=protected-access - - -class _SendFailed(Exception): - """Custom exception to abort sending a request.""" + return self._response._readinto(buf) class OutOfRetries(Exception): @@ -95,15 +85,29 @@ class OutOfRetries(Exception): class Response: """The response from a request, contains all the headers/content""" - # pylint: disable=too-many-instance-attributes - encoding = None + socket: SocketType + """The underlying socket object (CircuitPython extension, not in standard requests) + + Under the following circumstances, calling code may directly access the underlying + socket object: + + * The request was made with ``stream=True`` + * The request headers included ``{'connection': 'close'}`` + * No methods or properties on the Response object that access the response content + may be used + + Methods and properties that access response headers may be accessed. - def __init__(self, sock, session=None): + It is still necessary to ``close`` the response object for correct management of + sockets, including doing so implicitly via ``with requests.get(...) as response``.""" + + def __init__(self, sock: SocketType, session: "Session", method: str) -> None: self.socket = sock self.encoding = "utf-8" self._cached = None self._headers = {} + self._method = method # _start_index and _receive_buffer are used when parsing headers. # _receive_buffer will grow by 32 bytes everytime it is too small. @@ -112,86 +116,51 @@ def __init__(self, sock, session=None): self._remaining = None self._chunked = False - self._backwards_compatible = not hasattr(sock, "recv_into") - http = self._readto(b" ") if not http: - if session: - session._close_socket(self.socket) - else: - self.socket.close() + session._connection_manager.close_socket(self.socket) raise RuntimeError("Unable to read HTTP response.") - self.status_code = int(bytes(self._readto(b" "))) - self.reason = self._readto(b"\r\n") + self.status_code: int = int(bytes(self._readto(b" "))) + """The status code returned by the server""" + self.reason: bytearray = self._readto(b"\r\n") + """The status reason returned by the server""" self._parse_headers() self._raw = None self._session = session - def __enter__(self): + def __enter__(self) -> "Response": return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__( + self, + exc_type: Optional[Type[type]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: self.close() - def _recv_into(self, buf, size=0): - if self._backwards_compatible: - size = len(buf) if size == 0 else size - b = self.socket.recv(size) - read_size = len(b) - buf[:read_size] = b - return read_size + def _recv_into(self, buf: bytearray, size: int = 0) -> int: return self.socket.recv_into(buf, size) - @staticmethod - def _find(buf, needle, start, end): - if hasattr(buf, "find"): - return buf.find(needle, start, end) - result = -1 - i = start - while i < end: - j = 0 - while j < len(needle) and i + j < end and buf[i + j] == needle[j]: - j += 1 - if j == len(needle): - result = i - break - i += 1 - - return result - - def _readto(self, first, second=b""): + def _readto(self, stop: bytes) -> bytearray: buf = self._receive_buffer end = self._received_length while True: - firsti = self._find(buf, first, 0, end) - secondi = -1 - if second: - secondi = self._find(buf, second, 0, end) - - i = -1 - needle_len = 0 - if firsti >= 0: - i = firsti - needle_len = len(first) - if secondi >= 0 and (firsti < 0 or secondi < firsti): - i = secondi - needle_len = len(second) + i = buf.find(stop, 0, end) if i >= 0: + # Stop was found. Return everything up to but not including stop. result = buf[:i] - new_start = i + needle_len - - if i + needle_len <= end: - new_end = end - new_start - buf[:new_end] = buf[new_start:end] - self._received_length = new_end + new_start = i + len(stop) + # Remove everything up to and including stop from the buffer. + new_end = end - new_start + buf[:new_end] = buf[new_start:end] + self._received_length = new_end return result - # Not found so load more. - + # Not found so load more bytes. # If our buffer is full, then make it bigger to load more. if end == len(buf): - new_size = len(buf) + 32 - new_buf = bytearray(new_size) + new_buf = bytearray(len(buf) + 32) new_buf[: len(buf)] = buf buf = new_buf self._receive_buffer = buf @@ -202,9 +171,9 @@ def _readto(self, first, second=b""): return buf[:end] end += read - return b"" - - def _read_from_buffer(self, buf=None, nbytes=None): + def _read_from_buffer( + self, buf: Optional[bytearray] = None, nbytes: Optional[int] = None + ) -> int: if self._received_length == 0: return 0 read = self._received_length @@ -221,11 +190,9 @@ def _read_from_buffer(self, buf=None, nbytes=None): self._received_length = 0 return read - def _readinto(self, buf): + def _readinto(self, buf: bytearray) -> int: if not self.socket: - raise RuntimeError( - "Newer Response closed this one. Use Responses immediately." - ) + raise RuntimeError("Newer Response closed this one. Use Responses immediately.") if not self._remaining: # Consume the chunk header if need be. @@ -233,88 +200,102 @@ def _readinto(self, buf): # Consume trailing \r\n for chunks 2+ if self._remaining == 0: self._throw_away(2) - chunk_header = _buffer_split0(self._readto(b"\r\n"), b";") + chunk_header = bytes(self._readto(b"\r\n")).split(b";", 1)[0] http_chunk_size = int(bytes(chunk_header), 16) if http_chunk_size == 0: self._chunked = False self._parse_headers() return 0 self._remaining = http_chunk_size + elif self._remaining is None: + # the Content-Length is not provided in the HTTP header + # so try parsing as long as their is data in the socket + pass else: return 0 nbytes = len(buf) - if nbytes > self._remaining: - nbytes = self._remaining + if self._remaining and nbytes > self._remaining: + # if Content-Length was provided and remaining bytes larges than buffer + nbytes = self._remaining # adjust read amount read = self._read_from_buffer(buf, nbytes) if read == 0: read = self._recv_into(buf, nbytes) - self._remaining -= read + if self._remaining: + # if Content-Length was provided, adjust the remaining amount to still read + self._remaining -= read return read - def _throw_away(self, nbytes): + def _throw_away(self, nbytes: int) -> None: nbytes -= self._read_from_buffer(nbytes=nbytes) buf = self._receive_buffer - for _ in range(nbytes // len(buf)): - self._recv_into(buf) - remaining = nbytes % len(buf) - if remaining: - self._recv_into(buf, remaining) - - def close(self): - """Drain the remaining ESP socket buffers. We assume we already got what we wanted.""" + len_buf = len(buf) + for _ in range(nbytes // len_buf): + to_read = len_buf + while to_read > 0: + to_read -= self._recv_into(buf, to_read) + to_read = nbytes % len_buf + while to_read > 0: + to_read -= self._recv_into(buf, to_read) + + def close(self) -> None: + """Close out the socket. If we have a session free it instead.""" if not self.socket: return - # Make sure we've read all of our response. - if self._cached is None: - if self._remaining and self._remaining > 0: - self._throw_away(self._remaining) - elif self._chunked: - while True: - chunk_header = self._readto(b"\r\n").split(b";", 1)[0] - chunk_size = int(bytes(chunk_header), 16) - if chunk_size == 0: - break - self._throw_away(chunk_size + 2) - self._parse_headers() + if self._session: - self._session._free_socket(self.socket) # pylint: disable=protected-access + self._session._connection_manager.free_socket(self.socket) else: self.socket.close() + self.socket = None - def _parse_headers(self): + def _parse_headers(self) -> None: """ Parses the header portion of an HTTP request/response from the socket. Expects first line of HTTP request/response to have been read already. """ while True: - title = self._readto(b": ", b"\r\n") - if not title: + header = self._readto(b"\r\n") + if not header: break - - content = self._readto(b"\r\n") + title, content = bytes(header).split(b":", 1) + content = content.strip() if title and content: - title = str(title, "utf-8") + # enforce that all headers are lowercase + title = str(title, "utf-8").lower() content = str(content, "utf-8") - # Check len first so we can skip the .lower allocation most of the time. - if ( - len(title) == len("content-length") - and title.lower() == "content-length" - ): + if title == "content-length": self._remaining = int(content) - if ( - len(title) == len("transfer-encoding") - and title.lower() == "transfer-encoding" - ): - self._chunked = content.lower() == "chunked" - self._headers[title] = content + if title == "transfer-encoding": + self._chunked = content.strip().lower() == "chunked" + if title == "set-cookie" and title in self._headers: + self._headers[title] += ", " + content + else: + self._headers[title] = content + + # does the body have a fixed length? (of zero) + if ( + self.status_code == 204 + or self.status_code == 304 + or 100 <= self.status_code < 200 # 1xx codes + or self._method == "HEAD" + ): + self._remaining = 0 + + def _validate_not_gzip(self) -> None: + """gzip encoding is not supported. Raise an exception if found.""" + if "content-encoding" in self.headers and self.headers["content-encoding"] == "gzip": + raise ValueError( + "Content-encoding is gzip, data cannot be accessed as json or text. " + "Use content property to access raw bytes." + ) @property - def headers(self): + def headers(self) -> Dict[str, str]: """ The response headers. Does not include headers from the trailer until the content has been read. @@ -322,7 +303,7 @@ def headers(self): return self._headers @property - def content(self): + def content(self) -> bytes: """The HTTP content direct from the socket, as bytes""" if self._cached is not None: if isinstance(self._cached, bytes): @@ -333,21 +314,21 @@ def content(self): return self._cached @property - def text(self): + def text(self) -> str: """The HTTP content, encoded into a string according to the HTTP header encoding""" if self._cached is not None: if isinstance(self._cached, str): return self._cached raise RuntimeError("Cannot access text after getting content or json") + + self._validate_not_gzip() + self._cached = str(self.content, self.encoding) return self._cached - def json(self): + def json(self) -> Any: """The HTTP content, parsed into a json dictionary""" - # pylint: disable=import-outside-toplevel - import json - # The cached JSON will be a list or dictionary. if self._cached: if isinstance(self._cached, (list, dict)): @@ -356,18 +337,15 @@ def json(self): if not self._raw: self._raw = _RawResponse(self) - try: - obj = json.load(self._raw) - except OSError: - # <5.3.1 doesn't piecemeal load json from any object with readinto so load the whole - # string. - obj = json.loads(self._raw.read()) + self._validate_not_gzip() + + obj = json_module.load(self._raw) if not self._cached: self._cached = obj - self.close() + return obj - def iter_content(self, chunk_size=1, decode_unicode=False): + def iter_content(self, chunk_size: int = 1, decode_unicode: bool = False) -> bytes: """An iterator that will stream data by only reading 'chunk_size' bytes and yielding them, when we can't buffer the whole datastream""" if decode_unicode: @@ -389,154 +367,238 @@ def iter_content(self, chunk_size=1, decode_unicode=False): class Session: """HTTP session that shares sockets and ssl context.""" - def __init__(self, socket_pool, ssl_context=None): - self._socket_pool = socket_pool + def __init__( + self, + socket_pool: SocketpoolModuleType, + ssl_context: Optional[SSLContextType] = None, + session_id: Optional[str] = None, + ) -> None: + self._connection_manager = get_connection_manager(socket_pool) self._ssl_context = ssl_context - # Hang onto open sockets so that we can reuse them. - self._open_sockets = {} - self._socket_free = {} + self._session_id = session_id self._last_response = None - def _free_socket(self, socket): - if socket not in self._open_sockets.values(): - raise RuntimeError("Socket not from session") - self._socket_free[socket] = True - - def _close_socket(self, sock): - sock.close() - del self._socket_free[sock] - key = None - for k in self._open_sockets: - if self._open_sockets[k] == sock: - key = k - break - if key: - del self._open_sockets[key] - - def _free_sockets(self): - free_sockets = [] - for sock in self._socket_free: - if self._socket_free[sock]: - free_sockets.append(sock) - for sock in free_sockets: - self._close_socket(sock) - - def _get_socket(self, host, port, proto, *, timeout=1): - key = (host, port, proto) - if key in self._open_sockets: - sock = self._open_sockets[key] - if self._socket_free[sock]: - self._socket_free[sock] = False - return sock - if proto == "https:" and not self._ssl_context: - raise RuntimeError( - "ssl_context must be set before using adafruit_requests for https" + def _build_boundary_data(self, files: dict): # pylint: disable=too-many-locals + boundary_string = self._build_boundary_string() + content_length = 0 + boundary_objects = [] + + for field_name, field_values in files.items(): + file_name = field_values[0] + file_handle = field_values[1] + + boundary_objects.append( + f'--{boundary_string}\r\nContent-Disposition: form-data; name="{field_name}"' ) - addr_info = self._socket_pool.getaddrinfo( - host, port, 0, self._socket_pool.SOCK_STREAM - )[0] - retry_count = 0 - sock = None - while retry_count < 5 and sock is None: - if retry_count > 0: - if any(self._socket_free.items()): - self._free_sockets() - else: - raise RuntimeError("Sending request failed") - retry_count += 1 + if file_name is not None: + boundary_objects.append(f'; filename="{file_name}"') + boundary_objects.append("\r\n") + if len(field_values) >= 3: + file_content_type = field_values[2] + boundary_objects.append(f"Content-Type: {file_content_type}\r\n") + if len(field_values) >= 4: + file_headers = field_values[3] + for file_header_key, file_header_value in file_headers.items(): + boundary_objects.append(f"{file_header_key}: {file_header_value}\r\n") + boundary_objects.append("\r\n") - try: - sock = self._socket_pool.socket( - addr_info[0], addr_info[1], addr_info[2] - ) - except OSError: - continue + if hasattr(file_handle, "read"): + content_length += self._get_file_length(file_handle) - connect_host = addr_info[-1][0] - if proto == "https:": - sock = self._ssl_context.wrap_socket(sock, server_hostname=host) - connect_host = host - sock.settimeout(timeout) # socket read timeout + boundary_objects.append(file_handle) + boundary_objects.append("\r\n") - try: - sock.connect((connect_host, port)) - except MemoryError: - sock.close() - sock = None - except OSError: - sock.close() - sock = None + boundary_objects.append(f"--{boundary_string}--\r\n") + + for boundary_object in boundary_objects: + if isinstance(boundary_object, str): + content_length += len(boundary_object) + + return boundary_string, content_length, boundary_objects + + @staticmethod + def _build_boundary_string(): + return os.urandom(16).hex() - if sock is None: - raise RuntimeError("Repeated socket failures") + @staticmethod + def _check_headers(headers: Dict[str, str]): + if not isinstance(headers, dict): + raise TypeError("Headers must be in dict format") - self._open_sockets[key] = sock - self._socket_free[sock] = False - return sock + for key, value in headers.items(): + if isinstance(value, (str, bytes)) or value is None: + continue + raise TypeError( + f"Header part ({value}) from {key} must be of type str or bytes, not {type(value)}" + ) @staticmethod - def _send(socket, data): + def _get_file_length(file_handle: IO): + is_binary = False + try: + file_handle.seek(0) + # read at least 4 bytes incase we are reading a b64 stream + content = file_handle.read(4) + is_binary = isinstance(content, bytes) + except UnicodeError: + is_binary = False + + if not is_binary: + raise ValueError("Files must be opened in binary mode") + + file_handle.seek(0, SEEK_END) + content_length = file_handle.tell() + file_handle.seek(0) + return content_length + + @staticmethod + def _send(socket: SocketType, data: bytes): total_sent = 0 while total_sent < len(data): - # ESP32SPI sockets raise a RuntimeError when unable to send. try: sent = socket.send(data[total_sent:]) - except RuntimeError: - sent = 0 + except OSError as exc: + if exc.errno == errno.EAGAIN: + # Can't send right now (e.g., no buffer space), try again. + continue + # Some worse error. + raise + except RuntimeError as exc: + # ESP32SPI sockets raise a RuntimeError when unable to send. + raise OSError(errno.EIO) from exc if sent is None: sent = len(data) if sent == 0: - raise _SendFailed() + # Not EAGAIN; that was already handled. + raise OSError(errno.EIO) total_sent += sent - def _send_request(self, socket, host, method, path, headers, data, json): - # pylint: disable=too-many-arguments - self._send(socket, bytes(method, "utf-8")) - self._send(socket, b" /") - self._send(socket, bytes(path, "utf-8")) - self._send(socket, b" HTTP/1.1\r\n") - if "Host" not in headers: - self._send(socket, b"Host: ") - self._send(socket, bytes(host, "utf-8")) - self._send(socket, b"\r\n") - if "User-Agent" not in headers: - self._send(socket, b"User-Agent: Adafruit CircuitPython\r\n") - # Iterate over keys to avoid tuple alloc - for k in headers: - self._send(socket, k.encode()) - self._send(socket, b": ") - self._send(socket, headers[k].encode()) - self._send(socket, b"\r\n") + def _send_as_bytes(self, socket: SocketType, data: str): + return self._send(socket, bytes(data, "utf-8")) + + def _send_boundary_objects(self, socket: SocketType, boundary_objects: Any): + for boundary_object in boundary_objects: + if isinstance(boundary_object, str): + self._send_as_bytes(socket, boundary_object) + else: + self._send_file(socket, boundary_object) + + def _send_file(self, socket: SocketType, file_handle: IO): + chunk_size = 36 + b = bytearray(chunk_size) + while True: + size = file_handle.readinto(b) + if size == 0: + break + self._send(socket, b[:size]) + + def _send_header(self, socket, header, value): + if value is None: + return + self._send_as_bytes(socket, header) + self._send(socket, b": ") + if isinstance(value, bytes): + self._send(socket, value) + else: + self._send_as_bytes(socket, value) + self._send(socket, b"\r\n") + + # noqa: PLR0912 Too many branches + def _send_request( # noqa: PLR0913,PLR0912 Too many arguments in function definition,Too many branches + self, + socket: SocketType, + host: str, + method: str, + path: str, + headers: Dict[str, str], + data: Any, + json: Any, + files: Optional[Dict[str, tuple]], + ): + # Check headers + self._check_headers(headers) + + # Convert data + content_type_header = None + + # If json is sent, set content type header and convert to string if json is not None: assert data is None - # pylint: disable=import-outside-toplevel - try: - import json as json_module - except ImportError: - import ujson as json_module + assert files is None + content_type_header = "application/json" data = json_module.dumps(json) - self._send(socket, b"Content-Type: application/json\r\n") - if data: - if isinstance(data, dict): - self._send( - socket, b"Content-Type: application/x-www-form-urlencoded\r\n" - ) - _post_data = "" - for k in data: - _post_data = "{}&{}={}".format(_post_data, k, data[k]) - data = _post_data[1:] - self._send(socket, b"Content-Length: %d\r\n" % len(data)) + + # If data is sent and it's a dict, set content type header and convert to string + if data and isinstance(data, dict): + assert files is None + content_type_header = "application/x-www-form-urlencoded" + _post_data = "" + for k in data: + _post_data = f"{_post_data}&{k}={data[k]}" + # remove first "&" from concatenation + data = _post_data[1:] + + # Convert str data to bytes + if data and isinstance(data, str): + data = bytes(data, "utf-8") + + # If files are send, build data to send and calculate length + content_length = 0 + data_is_file = False + boundary_objects = None + if files and isinstance(files, dict): + boundary_string, content_length, boundary_objects = self._build_boundary_data(files) + content_type_header = f"multipart/form-data; boundary={boundary_string}" + elif data and hasattr(data, "read"): + data_is_file = True + content_length = self._get_file_length(data) + else: + if data is None: + data = b"" + content_length = len(data) + + self._send_as_bytes(socket, method) + self._send(socket, b" /") + self._send_as_bytes(socket, path) + self._send(socket, b" HTTP/1.1\r\n") + + # create lower-case supplied header list + supplied_headers = {header.lower() for header in headers} + + # Send headers + if not "host" in supplied_headers: + self._send_header(socket, "Host", host) + if not "user-agent" in supplied_headers: + self._send_header(socket, "User-Agent", "Adafruit CircuitPython") + if content_type_header and not "content-type" in supplied_headers: + self._send_header(socket, "Content-Type", content_type_header) + if (data or files) and not "content-length" in supplied_headers: + self._send_header(socket, "Content-Length", str(content_length)) + # Iterate over keys to avoid tuple alloc + for header in headers: + self._send_header(socket, header, headers[header]) self._send(socket, b"\r\n") - if data: - if isinstance(data, bytearray): - self._send(socket, bytes(data)) - else: - self._send(socket, bytes(data, "utf-8")) - # pylint: disable=too-many-branches, too-many-statements, unused-argument, too-many-arguments, too-many-locals - def request( - self, method, url, data=None, json=None, headers=None, stream=False, timeout=60 - ): + # Send data + if data_is_file: + self._send_file(socket, data) + elif data: + self._send(socket, bytes(data)) + elif boundary_objects: + self._send_boundary_objects(socket, boundary_objects) + + def request( # noqa: PLR0912,PLR0913,PLR0915 Too many branches,Too many arguments in function definition,Too many statements + self, + method: str, + url: str, + data: Optional[Any] = None, + json: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, + stream: bool = False, + timeout: float = 60, + allow_redirects: bool = True, + files: Optional[Dict[str, tuple]] = None, + ) -> Response: """Perform an HTTP request to the given url which we will parse to determine whether to use SSL ('https://') or not. We can also send some provided 'data' or a json dictionary which we will stringify. 'headers' is optional HTTP headers @@ -570,143 +632,106 @@ def request( # We may fail to send the request if the socket we got is closed already. So, try a second # time in that case. + # Note that the loop below actually tries a second time in other failure cases too, + # namely timeout and no data from socket. This was not covered in the stated intent of the + # commit that introduced the loop, but removing the retry from those cases could prove + # problematic to callers that now depend on that resiliency. retry_count = 0 + last_exc = None while retry_count < 2: retry_count += 1 - socket = self._get_socket(host, port, proto, timeout=timeout) + socket = self._connection_manager.get_socket( + host, + port, + proto, + session_id=self._session_id, + timeout=timeout, + ssl_context=self._ssl_context, + ) ok = True try: - self._send_request(socket, host, method, path, headers, data, json) - except _SendFailed: + self._send_request(socket, host, method, path, headers, data, json, files) + except OSError as exc: + last_exc = exc ok = False if ok: # Read the H of "HTTP/1.1" to make sure the socket is alive. send can appear to work # even when the socket is closed. - if hasattr(socket, "recv"): - result = socket.recv(1) - else: - result = bytearray(1) - socket.recv_into(result) - if result == b"H": - # Things seem to be ok so break with socket set. - break - self._close_socket(socket) + # Both recv/recv_into can raise OSError; when that happens, we need to call + # _connection_manager.close_socket(socket) or future calls to + # _connection_manager.get_socket() for the same parameter set will fail + try: + if hasattr(socket, "recv"): + result = socket.recv(1) + else: + result = bytearray(1) + socket.recv_into(result) + if result == b"H": + # Things seem to be ok so break with socket set. + break + else: + raise RuntimeError("no data from socket") + except (OSError, RuntimeError) as exc: + last_exc = exc + pass + self._connection_manager.close_socket(socket) socket = None if not socket: - raise OutOfRetries() + raise OutOfRetries("Repeated socket failures") from last_exc + + resp = Response(socket, self, method) # our response + if allow_redirects: + if "location" in resp.headers and 300 <= resp.status_code <= 399: + # a naive handler for redirects + redirect = resp.headers["location"] + + if redirect.startswith("http"): + # absolute URL + url = redirect + elif redirect[0] == "/": + # relative URL, absolute path + url = "/".join([proto, dummy, host, redirect[1:]]) + else: + # relative URL, relative path + path = path.rsplit("/", 1)[0] + + while redirect.startswith("../"): + path = path.rsplit("/", 1)[0] + redirect = redirect.split("../", 1)[1] + + url = "/".join([proto, dummy, host, path, redirect]) - resp = Response(socket, self) # our response - if "location" in resp.headers and 300 <= resp.status_code <= 399: - raise NotImplementedError("Redirects not yet supported") + self._last_response = resp + resp = self.request(method, url, data, json, headers, stream, timeout) self._last_response = resp return resp - def head(self, url, **kw): + def options(self, url: str, **kw) -> Response: + """Send HTTP OPTIONS request""" + return self.request("OPTIONS", url, **kw) + + def head(self, url: str, **kw) -> Response: """Send HTTP HEAD request""" return self.request("HEAD", url, **kw) - def get(self, url, **kw): + def get(self, url: str, **kw) -> Response: """Send HTTP GET request""" return self.request("GET", url, **kw) - def post(self, url, **kw): + def post(self, url: str, **kw) -> Response: """Send HTTP POST request""" return self.request("POST", url, **kw) - def put(self, url, **kw): + def put(self, url: str, **kw) -> Response: """Send HTTP PUT request""" return self.request("PUT", url, **kw) - def patch(self, url, **kw): + def patch(self, url: str, **kw) -> Response: """Send HTTP PATCH request""" return self.request("PATCH", url, **kw) - def delete(self, url, **kw): + def delete(self, url: str, **kw) -> Response: """Send HTTP DELETE request""" return self.request("DELETE", url, **kw) - - -# Backwards compatible API: - -_default_session = None # pylint: disable=invalid-name - - -class _FakeSSLSocket: - def __init__(self, socket, tls_mode): - self._socket = socket - self._mode = tls_mode - self.settimeout = socket.settimeout - self.send = socket.send - self.recv = socket.recv - self.close = socket.close - - def connect(self, address): - """connect wrapper to add non-standard mode parameter""" - try: - return self._socket.connect(address, self._mode) - except RuntimeError as error: - raise OSError(errno.ENOMEM) from error - - -class _FakeSSLContext: - def __init__(self, iface): - self._iface = iface - - def wrap_socket(self, socket, server_hostname=None): - """Return the same socket""" - # pylint: disable=unused-argument - return _FakeSSLSocket(socket, self._iface.TLS_MODE) - - -def set_socket(sock, iface=None): - """Legacy API for setting the socket and network interface. Use a `Session` instead.""" - global _default_session # pylint: disable=global-statement,invalid-name - _default_session = Session(sock, _FakeSSLContext(iface)) - if iface: - sock.set_interface(iface) - - -def request(method, url, data=None, json=None, headers=None, stream=False, timeout=1): - """Send HTTP request""" - # pylint: disable=too-many-arguments - _default_session.request( - method, - url, - data=data, - json=json, - headers=headers, - stream=stream, - timeout=timeout, - ) - - -def head(url, **kw): - """Send HTTP HEAD request""" - return _default_session.request("HEAD", url, **kw) - - -def get(url, **kw): - """Send HTTP GET request""" - return _default_session.request("GET", url, **kw) - - -def post(url, **kw): - """Send HTTP POST request""" - return _default_session.request("POST", url, **kw) - - -def put(url, **kw): - """Send HTTP PUT request""" - return _default_session.request("PUT", url, **kw) - - -def patch(url, **kw): - """Send HTTP PATCH request""" - return _default_session.request("PATCH", url, **kw) - - -def delete(url, **kw): - """Send HTTP DELETE request""" - return _default_session.request("DELETE", url, **kw) diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000..d60cf4b --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,8 @@ +/* SPDX-FileCopyrightText: 2025 Sam Blenny + * SPDX-License-Identifier: MIT + */ + +/* Monkey patch the rtd theme to prevent horizontal stacking of short items + * see https://github.com/readthedocs/sphinx_rtd_theme/issues/1301 + */ +.py.property{display: block !important;} diff --git a/docs/_static/favicon.ico.license b/docs/_static/favicon.ico.license new file mode 100644 index 0000000..86a3fbf --- /dev/null +++ b/docs/_static/favicon.ico.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2018 Phillip Torrone for Adafruit Industries + +SPDX-License-Identifier: CC-BY-4.0 diff --git a/docs/api.rst.license b/docs/api.rst.license new file mode 100644 index 0000000..9aae48d --- /dev/null +++ b/docs/api.rst.license @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2020 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: MIT diff --git a/docs/conf.py b/docs/conf.py index 5bbe8de..132c2eb 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,8 @@ -# -*- coding: utf-8 -*- +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: MIT +import datetime import os import sys @@ -12,6 +15,7 @@ # ones. extensions = [ "sphinx.ext.autodoc", + "sphinxcontrib.jquery", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx.ext.todo", @@ -25,8 +29,8 @@ intersphinx_mapping = { - "python": ("https://docs.python.org/3.4", None), - "CircuitPython": ("https://circuitpython.readthedocs.io/en/latest/", None), + "python": ("https://docs.python.org/3", None), + "CircuitPython": ("https://docs.circuitpython.org/en/latest/", None), } # Add any paths that contain templates here, relative to this directory. @@ -39,7 +43,12 @@ # General information about the project. project = "Adafruit Requests Library" -copyright = "2019 ladyada" +creation_year = "2019" +current_year = str(datetime.datetime.now().year) +year_duration = ( + current_year if current_year == creation_year else creation_year + " - " + current_year +) +copyright = year_duration + " ladyada" author = "ladyada" # The version info for the project you're documenting, acts as replacement for @@ -56,7 +65,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -88,25 +97,18 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -on_rtd = os.environ.get("READTHEDOCS", None) == "True" - -if not on_rtd: # only import and set the theme if we're building docs locally - try: - import sphinx_rtd_theme +import sphinx_rtd_theme - html_theme = "sphinx_rtd_theme" - html_theme_path = [sphinx_rtd_theme.get_html_theme_path(), "."] - except: - html_theme = "default" - html_theme_path = ["."] -else: - html_theme_path = ["."] +html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] +# Include extra css to work around rtd theme glitches +html_css_files = ["custom.css"] + # The name of an image file (relative to this directory) to use as a favicon of # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. diff --git a/docs/examples.rst b/docs/examples.rst index 4c95536..3637216 100755 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,8 +1,32 @@ -Simple test ------------- +Examples +======== -Ensure your device works with this simple test. +Below are a few examples, for use with common boards. There are more in the examples folder of the library -.. literalinclude:: ../examples/requests_simpletest.py - :caption: examples/requests_simpletest.py +On-board WiFi +-------------- + +.. literalinclude:: ../examples/wifi/requests_wifi_simpletest.py + :caption: examples/wifi/requests_wifi_simpletest.py + :linenos: + +ESP32SPI +-------------- + +.. literalinclude:: ../examples/esp32spi/requests_esp32spi_simpletest.py + :caption: examples/esp32spi/requests_esp32spi_simpletest.py + :linenos: + +WIZNET5K +-------------- + +.. literalinclude:: ../examples/wiznet5k/requests_wiznet5k_simpletest.py + :caption: examples/wiznet5k/requests_wiznet5k_simpletest.py + :linenos: + +Fona +-------------- + +.. literalinclude:: ../examples/fona/requests_fona_simpletest.py + :caption: examples/fona/requests_fona_simpletest.py :linenos: diff --git a/docs/examples.rst.license b/docs/examples.rst.license new file mode 100644 index 0000000..9aae48d --- /dev/null +++ b/docs/examples.rst.license @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2020 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: MIT diff --git a/docs/index.rst b/docs/index.rst index 2985f95..006b766 100755 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,8 +29,9 @@ Table of Contents .. toctree:: :caption: Other Links - Download - CircuitPython Reference Documentation + Download from GitHub + Download Library Bundle + CircuitPython Reference Documentation CircuitPython Support Forum Discord Chat Adafruit Learning System diff --git a/docs/index.rst.license b/docs/index.rst.license new file mode 100644 index 0000000..9aae48d --- /dev/null +++ b/docs/index.rst.license @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2020 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: MIT diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..979f568 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2021 Kattni Rembor for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +sphinx +sphinxcontrib-jquery +sphinx-rtd-theme diff --git a/examples/cpython/requests_cpython_advanced.py b/examples/cpython/requests_cpython_advanced.py new file mode 100644 index 0000000..b19b37b --- /dev/null +++ b/examples/cpython/requests_cpython_advanced.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-License-Identifier: MIT + +import socket as pool +import ssl + +import adafruit_requests + +# Initialize a requests session +requests = adafruit_requests.Session(pool, ssl.create_default_context()) + +JSON_GET_URL = "https://httpbin.org/get" + +# Define a custom header as a dict. +headers = {"user-agent": "blinka/1.0.0"} + +print("Fetching JSON data from %s..." % JSON_GET_URL) +with requests.get(JSON_GET_URL, headers=headers) as response: + print("-" * 60) + json_data = response.json() + headers = json_data["headers"] + print("Response's Custom User-Agent Header: {0}".format(headers["User-Agent"])) + print("-" * 60) + + # Read Response's HTTP status code + print("Response HTTP Status Code: ", response.status_code) + print("-" * 60) diff --git a/examples/cpython/requests_cpython_simpletest.py b/examples/cpython/requests_cpython_simpletest.py new file mode 100644 index 0000000..953e09c --- /dev/null +++ b/examples/cpython/requests_cpython_simpletest.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-License-Identifier: MIT + +import socket as pool +import ssl + +import adafruit_requests + +# Initialize a requests session +requests = adafruit_requests.Session(pool, ssl.create_default_context()) + +TEXT_URL = "http://wifitest.adafruit.com/testwifi/index.html" +JSON_GET_URL = "https://httpbin.org/get" +JSON_POST_URL = "https://httpbin.org/post" + +print("Fetching text from %s" % TEXT_URL) +with requests.get(TEXT_URL) as response: + print("-" * 40) + print("Text Response: ", response.text) + print("-" * 40) + +print("Fetching JSON data from %s" % JSON_GET_URL) +with requests.get(JSON_GET_URL) as response: + print("-" * 40) + print("JSON Response: ", response.json()) + print("-" * 40) + +data = "31F" +print(f"POSTing data to {JSON_POST_URL}: {data}") +with requests.post(JSON_POST_URL, data=data) as response: + print("-" * 40) + json_resp = response.json() + # Parse out the 'data' key from json_resp dict. + print("Data received from server:", json_resp["data"]) + print("-" * 40) + +json_data = {"Date": "July 25, 2019"} +print(f"POSTing data to {JSON_POST_URL}: {json_data}") +with requests.post(JSON_POST_URL, json=json_data) as response: + print("-" * 40) + json_resp = response.json() + # Parse out the 'json' key from json_resp dict. + print("JSON Data received from server:", json_resp["json"]) + print("-" * 40) diff --git a/examples/esp32spi/requests_esp32spi_advanced.py b/examples/esp32spi/requests_esp32spi_advanced.py new file mode 100644 index 0000000..170589f --- /dev/null +++ b/examples/esp32spi/requests_esp32spi_advanced.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-License-Identifier: MIT + +import os + +import adafruit_connection_manager +import board +import busio +from adafruit_esp32spi import adafruit_esp32spi +from digitalio import DigitalInOut + +import adafruit_requests + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +# If you are using a board with pre-defined ESP32 Pins: +esp32_cs = DigitalInOut(board.ESP_CS) +esp32_ready = DigitalInOut(board.ESP_BUSY) +esp32_reset = DigitalInOut(board.ESP_RESET) + +# If you have an externally connected ESP32: +# esp32_cs = DigitalInOut(board.D9) +# esp32_ready = DigitalInOut(board.D10) +# esp32_reset = DigitalInOut(board.D5) + +# If you have an AirLift Featherwing or ItsyBitsy Airlift: +# esp32_cs = DigitalInOut(board.D13) +# esp32_ready = DigitalInOut(board.D11) +# esp32_reset = DigitalInOut(board.D12) + +spi = busio.SPI(board.SCK, board.MOSI, board.MISO) +radio = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +print("Connecting to AP...") +while not radio.is_connected: + try: + radio.connect_AP(ssid, password) + except RuntimeError as e: + print("could not connect to AP, retrying: ", e) + continue +print("Connected to", str(radio.ap_info.ssid, "utf-8"), "\tRSSI:", radio.ap_info.rssi) + +# Initialize a requests session +pool = adafruit_connection_manager.get_radio_socketpool(radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio) +requests = adafruit_requests.Session(pool, ssl_context) + +JSON_GET_URL = "https://httpbin.org/get" + +# Define a custom header as a dict. +headers = {"user-agent": "blinka/1.0.0"} + +print("Fetching JSON data from %s..." % JSON_GET_URL) +with requests.get(JSON_GET_URL, headers=headers) as response: + print("-" * 60) + + json_data = response.json() + headers = json_data["headers"] + print("Response's Custom User-Agent Header: {0}".format(headers["User-Agent"])) + print("-" * 60) + + # Read Response's HTTP status code + print("Response HTTP Status Code: ", response.status_code) + print("-" * 60) diff --git a/examples/esp32spi/requests_esp32spi_simpletest.py b/examples/esp32spi/requests_esp32spi_simpletest.py new file mode 100644 index 0000000..3e856c0 --- /dev/null +++ b/examples/esp32spi/requests_esp32spi_simpletest.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-License-Identifier: MIT + +import os + +import adafruit_connection_manager +import board +import busio +from adafruit_esp32spi import adafruit_esp32spi +from digitalio import DigitalInOut + +import adafruit_requests + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +# If you are using a board with pre-defined ESP32 Pins: +esp32_cs = DigitalInOut(board.ESP_CS) +esp32_ready = DigitalInOut(board.ESP_BUSY) +esp32_reset = DigitalInOut(board.ESP_RESET) + +# If you have an externally connected ESP32: +# esp32_cs = DigitalInOut(board.D9) +# esp32_ready = DigitalInOut(board.D10) +# esp32_reset = DigitalInOut(board.D5) + +# If you have an AirLift Featherwing or ItsyBitsy Airlift: +# esp32_cs = DigitalInOut(board.D13) +# esp32_ready = DigitalInOut(board.D11) +# esp32_reset = DigitalInOut(board.D12) + +spi = busio.SPI(board.SCK, board.MOSI, board.MISO) +radio = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +print("Connecting to AP...") +while not radio.is_connected: + try: + radio.connect_AP(ssid, password) + except RuntimeError as e: + print("could not connect to AP, retrying: ", e) + continue +print("Connected to", str(radio.ap_info.ssid, "utf-8"), "\tRSSI:", radio.ap_info.rssi) + +# Initialize a requests session +pool = adafruit_connection_manager.get_radio_socketpool(radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio) +requests = adafruit_requests.Session(pool, ssl_context) + +TEXT_URL = "http://wifitest.adafruit.com/testwifi/index.html" +JSON_GET_URL = "https://httpbin.org/get" +JSON_POST_URL = "https://httpbin.org/post" + +print("Fetching text from %s" % TEXT_URL) +with requests.get(TEXT_URL) as response: + print("-" * 40) + print("Text Response: ", response.text) + print("-" * 40) + +print("Fetching JSON data from %s" % JSON_GET_URL) +with requests.get(JSON_GET_URL) as response: + print("-" * 40) + print("JSON Response: ", response.json()) + print("-" * 40) + +data = "31F" +print(f"POSTing data to {JSON_POST_URL}: {data}") +with requests.post(JSON_POST_URL, data=data) as response: + print("-" * 40) + json_resp = response.json() + # Parse out the 'data' key from json_resp dict. + print("Data received from server:", json_resp["data"]) + print("-" * 40) + +json_data = {"Date": "July 25, 2019"} +print(f"POSTing data to {JSON_POST_URL}: {json_data}") +with requests.post(JSON_POST_URL, json=json_data) as response: + print("-" * 40) + json_resp = response.json() + # Parse out the 'json' key from json_resp dict. + print("JSON Data received from server:", json_resp["json"]) + print("-" * 40) diff --git a/examples/fona/requests_fona_advanced.py b/examples/fona/requests_fona_advanced.py new file mode 100644 index 0000000..4a50b18 --- /dev/null +++ b/examples/fona/requests_fona_advanced.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-License-Identifier: MIT + +import os +import time + +import adafruit_connection_manager +import adafruit_fona.adafruit_fona_network as network +import adafruit_fona.adafruit_fona_socket as pool +import board +import busio +import digitalio +from adafruit_fona.adafruit_fona import FONA +from adafruit_fona.fona_3g import FONA3G + +import adafruit_requests + +# Get GPRS details, ensure these are setup in settings.toml +apn = os.getenv("APN") +apn_username = os.getenv("APN_USERNAME") +apn_password = os.getenv("APN_PASSWORD") + +# Create a serial connection for the FONA connection +uart = busio.UART(board.TX, board.RX) +rst = digitalio.DigitalInOut(board.D4) + +# Use this for FONA800 and FONA808 +radio = FONA(uart, rst) + +# Use this for FONA3G +# radio = FONA3G(uart, rst) + +# Initialize cellular data network +network = network.CELLULAR(radio, (apn, apn_username, apn_password)) + +while not network.is_attached: + print("Attaching to network...") + time.sleep(0.5) +print("Attached!") + +while not network.is_connected: + print("Connecting to network...") + network.connect() + time.sleep(0.5) +print("Network Connected!") + +# Initialize a requests session +ssl_context = adafruit_connection_manager.create_fake_ssl_context(pool, radio) +requests = adafruit_requests.Session(pool, ssl_context) + +JSON_GET_URL = "http://httpbin.org/get" + +# Define a custom header as a dict. +headers = {"user-agent": "blinka/1.0.0"} + +print("Fetching JSON data from %s..." % JSON_GET_URL) +with requests.get(JSON_GET_URL, headers=headers) as response: + print("-" * 60) + + json_data = response.json() + headers = json_data["headers"] + print("Response's Custom User-Agent Header: {0}".format(headers["User-Agent"])) + print("-" * 60) + + # Read Response's HTTP status code + print("Response HTTP Status Code: ", response.status_code) + print("-" * 60) diff --git a/examples/fona/requests_fona_simpletest.py b/examples/fona/requests_fona_simpletest.py new file mode 100644 index 0000000..a87bcba --- /dev/null +++ b/examples/fona/requests_fona_simpletest.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-License-Identifier: MIT + +import os +import time + +import adafruit_connection_manager +import adafruit_fona.adafruit_fona_network as network +import adafruit_fona.adafruit_fona_socket as pool +import board +import busio +import digitalio +from adafruit_fona.adafruit_fona import FONA +from adafruit_fona.fona_3g import FONA3G + +import adafruit_requests + +# Get GPRS details, ensure these are setup in settings.toml +apn = os.getenv("APN") +apn_username = os.getenv("APN_USERNAME") +apn_password = os.getenv("APN_PASSWORD") + +# Create a serial connection for the FONA connection +uart = busio.UART(board.TX, board.RX) +rst = digitalio.DigitalInOut(board.D4) + +# Use this for FONA800 and FONA808 +radio = FONA(uart, rst) + +# Use this for FONA3G +# radio = FONA3G(uart, rst) + +# Initialize cellular data network +network = network.CELLULAR(radio, (apn, apn_username, apn_password)) + +while not network.is_attached: + print("Attaching to network...") + time.sleep(0.5) +print("Attached!") + +while not network.is_connected: + print("Connecting to network...") + network.connect() + time.sleep(0.5) +print("Network Connected!") + +# Initialize a requests session +ssl_context = adafruit_connection_manager.create_fake_ssl_context(pool, radio) +requests = adafruit_requests.Session(pool, ssl_context) + +TEXT_URL = "http://wifitest.adafruit.com/testwifi/index.html" +JSON_GET_URL = "http://httpbin.org/get" +JSON_POST_URL = "http://httpbin.org/post" + +print("Fetching text from %s" % TEXT_URL) +with requests.get(TEXT_URL) as response: + print("-" * 40) + print("Text Response: ", response.text) + print("-" * 40) + +print("Fetching JSON data from %s" % JSON_GET_URL) +with requests.get(JSON_GET_URL) as response: + print("-" * 40) + print("JSON Response: ", response.json()) + print("-" * 40) + +data = "31F" +print(f"POSTing data to {JSON_POST_URL}: {data}") +with requests.post(JSON_POST_URL, data=data) as response: + print("-" * 40) + json_resp = response.json() + # Parse out the 'data' key from json_resp dict. + print("Data received from server:", json_resp["data"]) + print("-" * 40) + +json_data = {"Date": "July 25, 2019"} +print(f"POSTing data to {JSON_POST_URL}: {json_data}") +with requests.post(JSON_POST_URL, json=json_data) as response: + print("-" * 40) + json_resp = response.json() + # Parse out the 'json' key from json_resp dict. + print("JSON Data received from server:", json_resp["json"]) + print("-" * 40) diff --git a/examples/requests_advanced.py b/examples/requests_advanced.py deleted file mode 100644 index 6fb81d4..0000000 --- a/examples/requests_advanced.py +++ /dev/null @@ -1,66 +0,0 @@ -import board -import busio -from digitalio import DigitalInOut -import adafruit_esp32spi.adafruit_esp32spi_socket as socket -from adafruit_esp32spi import adafruit_esp32spi -import adafruit_requests as requests - -# Add a secrets.py to your filesystem that has a dictionary called secrets with "ssid" and -# "password" keys with your WiFi credentials. DO NOT share that file or commit it into Git or other -# source control. -# pylint: disable=no-name-in-module,wrong-import-order -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise - -# If you are using a board with pre-defined ESP32 Pins: -esp32_cs = DigitalInOut(board.ESP_CS) -esp32_ready = DigitalInOut(board.ESP_BUSY) -esp32_reset = DigitalInOut(board.ESP_RESET) - -# If you have an externally connected ESP32: -# esp32_cs = DigitalInOut(board.D9) -# esp32_ready = DigitalInOut(board.D10) -# esp32_reset = DigitalInOut(board.D5) - -spi = busio.SPI(board.SCK, board.MOSI, board.MISO) -esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) - -print("Connecting to AP...") -while not esp.is_connected: - try: - esp.connect_AP(secrets["ssid"], secrets["password"]) - except RuntimeError as e: - print("could not connect to AP, retrying: ", e) - continue -print("Connected to", str(esp.ssid, "utf-8"), "\tRSSI:", esp.rssi) - -# Initialize a requests object with a socket and esp32spi interface -socket.set_interface(esp) -requests.set_socket(socket) - -JSON_GET_URL = "http://httpbin.org/get" - -# Define a custom header as a dict. -headers = {"user-agent": "blinka/1.0.0"} - -print("Fetching JSON data from %s..." % JSON_GET_URL) -response = requests.get(JSON_GET_URL, headers=headers) -print("-" * 60) - -json_data = response.json() -headers = json_data["headers"] -print("Response's Custom User-Agent Header: {0}".format(headers["User-Agent"])) -print("-" * 60) - -# Read Response's HTTP status code -print("Response HTTP Status Code: ", response.status_code) -print("-" * 60) - -# Read Response, as raw bytes instead of pretty text -print("Raw Response: ", response.content) - -# Close, delete and collect the response data -response.close() diff --git a/examples/requests_advanced_cellular.py b/examples/requests_advanced_cellular.py deleted file mode 100755 index 18cc600..0000000 --- a/examples/requests_advanced_cellular.py +++ /dev/null @@ -1,70 +0,0 @@ -# pylint: disable=unused-import -import time -import board -import busio -import digitalio -from adafruit_fona.adafruit_fona import FONA -from adafruit_fona.fona_3g import FONA3G -import adafruit_fona.adafruit_fona_network as network -import adafruit_fona.adafruit_fona_socket as cellular_socket -import adafruit_requests as requests - -# Get GPRS details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("GPRS secrets are kept in secrets.py, please add them there!") - raise - -# Create a serial connection for the FONA connection -uart = busio.UART(board.TX, board.RX) -rst = digitalio.DigitalInOut(board.D9) - -# Use this for FONA800 and FONA808 -# fona = FONA(uart, rst) - -# Use this for FONA3G -fona = FONA3G(uart, rst) - -# Initialize cellular data network -network = network.CELLULAR( - fona, (secrets["apn"], secrets["apn_username"], secrets["apn_password"]) -) - -while not network.is_attached: - print("Attaching to network...") - time.sleep(0.5) -print("Attached!") - -while not network.is_connected: - print("Connecting to network...") - network.connect() - time.sleep(0.5) -print("Network Connected!") - -# Initialize a requests object with a socket and cellular interface -requests.set_socket(cellular_socket, fona) - -JSON_GET_URL = "http://httpbin.org/get" - -# Define a custom header as a dict. -headers = {"user-agent": "blinka/1.0.0"} - -print("Fetching JSON data from %s..." % JSON_GET_URL) -response = requests.get(JSON_GET_URL, headers=headers) -print("-" * 60) - -json_data = response.json() -headers = json_data["headers"] -print("Response's Custom User-Agent Header: {0}".format(headers["User-Agent"])) -print("-" * 60) - -# Read Response's HTTP status code -print("Response HTTP Status Code: ", response.status_code) -print("-" * 60) - -# Read Response, as raw bytes instead of pretty text -print("Raw Response: ", response.content) - -# Close, delete and collect the response data -response.close() diff --git a/examples/requests_advanced_cpython.py b/examples/requests_advanced_cpython.py deleted file mode 100644 index 379620e..0000000 --- a/examples/requests_advanced_cpython.py +++ /dev/null @@ -1,28 +0,0 @@ -import socket -import adafruit_requests - -http = adafruit_requests.Session(socket) - -JSON_GET_URL = "http://httpbin.org/get" - -# Define a custom header as a dict. -headers = {"user-agent": "blinka/1.0.0"} - -print("Fetching JSON data from %s..." % JSON_GET_URL) -response = http.get(JSON_GET_URL, headers=headers) -print("-" * 60) - -json_data = response.json() -headers = json_data["headers"] -print("Response's Custom User-Agent Header: {0}".format(headers["User-Agent"])) -print("-" * 60) - -# Read Response's HTTP status code -print("Response HTTP Status Code: ", response.status_code) -print("-" * 60) - -# Read Response, as raw bytes instead of pretty text -print("Raw Response: ", response.content) - -# Close, delete and collect the response data -response.close() diff --git a/examples/requests_advanced_ethernet.py b/examples/requests_advanced_ethernet.py deleted file mode 100644 index 2a0e7df..0000000 --- a/examples/requests_advanced_ethernet.py +++ /dev/null @@ -1,55 +0,0 @@ -import board -import busio -from digitalio import DigitalInOut -from adafruit_wiznet5k.adafruit_wiznet5k import WIZNET5K -import adafruit_wiznet5k.adafruit_wiznet5k_socket as socket -import adafruit_requests as requests - -cs = DigitalInOut(board.D10) -spi_bus = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO) - -# Initialize ethernet interface with DHCP -eth = WIZNET5K(spi_bus, cs) - -# Initialize a requests object with a socket and ethernet interface -requests.set_socket(socket, eth) - -JSON_GET_URL = "http://httpbin.org/get" - -attempts = 3 # Number of attempts to retry each request -failure_count = 0 -response = None - -# Define a custom header as a dict. -headers = {"user-agent": "blinka/1.0.0"} - -print("Fetching JSON data from %s..." % JSON_GET_URL) -while not response: - try: - response = requests.get(JSON_GET_URL, headers=headers) - failure_count = 0 - except AssertionError as error: - print("Request failed, retrying...\n", error) - failure_count += 1 - if failure_count >= attempts: - raise AssertionError( - "Failed to resolve hostname, \ - please check your router's DNS configuration." - ) from error - continue -print("-" * 60) - -json_data = response.json() -headers = json_data["headers"] -print("Response's Custom User-Agent Header: {0}".format(headers["User-Agent"])) -print("-" * 60) - -# Read Response's HTTP status code -print("Response HTTP Status Code: ", response.status_code) -print("-" * 60) - -# Read Response, as raw bytes instead of pretty text -print("Raw Response: ", response.content) - -# Close, delete and collect the response data -response.close() diff --git a/examples/requests_github_cpython.py b/examples/requests_github_cpython.py deleted file mode 100755 index e258d1f..0000000 --- a/examples/requests_github_cpython.py +++ /dev/null @@ -1,13 +0,0 @@ -# adafruit_requests usage with a CPython socket -import socket -import ssl -import adafruit_requests - -http = adafruit_requests.Session(socket, ssl.create_default_context()) - -print("Getting CircuitPython star count") -headers = {"Transfer-Encoding": "chunked"} -response = http.get( - "https://api.github.com/repos/adafruit/circuitpython", headers=headers -) -print("circuitpython stars", response.json()["stargazers_count"]) diff --git a/examples/requests_https_cpython.py b/examples/requests_https_cpython.py deleted file mode 100755 index 8013ffc..0000000 --- a/examples/requests_https_cpython.py +++ /dev/null @@ -1,45 +0,0 @@ -# adafruit_requests usage with a CPython socket -import socket -import ssl -import adafruit_requests as requests - -https = requests.Session(socket, ssl.create_default_context()) - -TEXT_URL = "https://httpbin.org/get" -JSON_GET_URL = "https://httpbin.org/get" -JSON_POST_URL = "https://httpbin.org/post" - -# print("Fetching text from %s" % TEXT_URL) -# response = requests.get(TEXT_URL) -# print("-" * 40) - -# print("Text Response: ", response.text) -# print("-" * 40) -# response.close() - -print("Fetching JSON data from %s" % JSON_GET_URL) -response = https.get(JSON_GET_URL) -print("-" * 40) - -print("JSON Response: ", response.json()) -print("-" * 40) - -data = "31F" -print("POSTing data to {0}: {1}".format(JSON_POST_URL, data)) -response = https.post(JSON_POST_URL, data=data) -print("-" * 40) - -json_resp = response.json() -# Parse out the 'data' key from json_resp dict. -print("Data received from server:", json_resp["data"]) -print("-" * 40) - -json_data = {"Date": "July 25, 2019"} -print("POSTing data to {0}: {1}".format(JSON_POST_URL, json_data)) -response = https.post(JSON_POST_URL, json=json_data) -print("-" * 40) - -json_resp = response.json() -# Parse out the 'json' key from json_resp dict. -print("JSON Data received from server:", json_resp["json"]) -print("-" * 40) diff --git a/examples/requests_simpletest.py b/examples/requests_simpletest.py deleted file mode 100755 index 4d4070e..0000000 --- a/examples/requests_simpletest.py +++ /dev/null @@ -1,85 +0,0 @@ -# adafruit_requests usage with an esp32spi_socket -import board -import busio -from digitalio import DigitalInOut -import adafruit_esp32spi.adafruit_esp32spi_socket as socket -from adafruit_esp32spi import adafruit_esp32spi -import adafruit_requests as requests - -# Add a secrets.py to your filesystem that has a dictionary called secrets with "ssid" and -# "password" keys with your WiFi credentials. DO NOT share that file or commit it into Git or other -# source control. -# pylint: disable=no-name-in-module,wrong-import-order -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise - -# If you are using a board with pre-defined ESP32 Pins: -esp32_cs = DigitalInOut(board.ESP_CS) -esp32_ready = DigitalInOut(board.ESP_BUSY) -esp32_reset = DigitalInOut(board.ESP_RESET) - -# If you have an externally connected ESP32: -# esp32_cs = DigitalInOut(board.D9) -# esp32_ready = DigitalInOut(board.D10) -# esp32_reset = DigitalInOut(board.D5) - -spi = busio.SPI(board.SCK, board.MOSI, board.MISO) -esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) - -print("Connecting to AP...") -while not esp.is_connected: - try: - esp.connect_AP(secrets["ssid"], secrets["password"]) - except RuntimeError as e: - print("could not connect to AP, retrying: ", e) - continue -print("Connected to", str(esp.ssid, "utf-8"), "\tRSSI:", esp.rssi) - -# Initialize a requests object with a socket and esp32spi interface -socket.set_interface(esp) -requests.set_socket(socket) - -TEXT_URL = "http://wifitest.adafruit.com/testwifi/index.html" -JSON_GET_URL = "http://httpbin.org/get" -JSON_POST_URL = "http://httpbin.org/post" - -print("Fetching text from %s" % TEXT_URL) -response = requests.get(TEXT_URL) -print("-" * 40) - -print("Text Response: ", response.text) -print("-" * 40) -response.close() - -print("Fetching JSON data from %s" % JSON_GET_URL) -response = requests.get(JSON_GET_URL) -print("-" * 40) - -print("JSON Response: ", response.json()) -print("-" * 40) -response.close() - -data = "31F" -print("POSTing data to {0}: {1}".format(JSON_POST_URL, data)) -response = requests.post(JSON_POST_URL, data=data) -print("-" * 40) - -json_resp = response.json() -# Parse out the 'data' key from json_resp dict. -print("Data received from server:", json_resp["data"]) -print("-" * 40) -response.close() - -json_data = {"Date": "July 25, 2019"} -print("POSTing data to {0}: {1}".format(JSON_POST_URL, json_data)) -response = requests.post(JSON_POST_URL, json=json_data) -print("-" * 40) - -json_resp = response.json() -# Parse out the 'json' key from json_resp dict. -print("JSON Data received from server:", json_resp["json"]) -print("-" * 40) -response.close() diff --git a/examples/requests_simpletest_cellular.py b/examples/requests_simpletest_cellular.py deleted file mode 100755 index 6727815..0000000 --- a/examples/requests_simpletest_cellular.py +++ /dev/null @@ -1,88 +0,0 @@ -# pylint: disable=unused-import -import time -import board -import busio -import digitalio -from adafruit_fona.adafruit_fona import FONA -from adafruit_fona.fona_3g import FONA3G -import adafruit_fona.adafruit_fona_network as network -import adafruit_fona.adafruit_fona_socket as cellular_socket -import adafruit_requests as requests - -# Get GPRS details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("GPRS secrets are kept in secrets.py, please add them there!") - raise - -# Create a serial connection for the FONA connection -uart = busio.UART(board.TX, board.RX) -rst = digitalio.DigitalInOut(board.D4) - -# Use this for FONA800 and FONA808 -fona = FONA(uart, rst) - -# Use this for FONA3G -# fona = FONA3G(uart, rst) - -# Initialize cellular data network -network = network.CELLULAR( - fona, (secrets["apn"], secrets["apn_username"], secrets["apn_password"]) -) - -while not network.is_attached: - print("Attaching to network...") - time.sleep(0.5) -print("Attached!") - -while not network.is_connected: - print("Connecting to network...") - network.connect() - time.sleep(0.5) -print("Network Connected!") - -# Initialize a requests object with a socket and cellular interface -requests.set_socket(cellular_socket, fona) - -TEXT_URL = "http://wifitest.adafruit.com/testwifi/index.html" -JSON_GET_URL = "http://httpbin.org/get" -JSON_POST_URL = "http://httpbin.org/post" - -print("Fetching text from %s" % TEXT_URL) -response = requests.get(TEXT_URL) -print("-" * 40) - -print("Text Response: ", response.text) -print("-" * 40) -response.close() - -print("Fetching JSON data from %s" % JSON_GET_URL) -response = requests.get(JSON_GET_URL) -print("-" * 40) - -print("JSON Response: ", response.json()) -print("-" * 40) -response.close() - -data = "31F" -print("POSTing data to {0}: {1}".format(JSON_POST_URL, data)) -response = requests.post(JSON_POST_URL, data=data) -print("-" * 40) - -json_resp = response.json() -# Parse out the 'data' key from json_resp dict. -print("Data received from server:", json_resp["data"]) -print("-" * 40) -response.close() - -json_data = {"Date": "July 25, 2019"} -print("POSTing data to {0}: {1}".format(JSON_POST_URL, json_data)) -response = requests.post(JSON_POST_URL, json=json_data) -print("-" * 40) - -json_resp = response.json() -# Parse out the 'json' key from json_resp dict. -print("JSON Data received from server:", json_resp["json"]) -print("-" * 40) -response.close() diff --git a/examples/requests_simpletest_cpython.py b/examples/requests_simpletest_cpython.py deleted file mode 100755 index db9fca2..0000000 --- a/examples/requests_simpletest_cpython.py +++ /dev/null @@ -1,44 +0,0 @@ -# adafruit_requests usage with a CPython socket -import socket -import adafruit_requests - -http = adafruit_requests.Session(socket) - -TEXT_URL = "http://wifitest.adafruit.com/testwifi/index.html" -JSON_GET_URL = "http://httpbin.org/get" -JSON_POST_URL = "http://httpbin.org/post" - -print("Fetching text from %s" % TEXT_URL) -response = http.get(TEXT_URL) -print("-" * 40) - -print("Text Response: ", response.text) -print("-" * 40) - -print("Fetching JSON data from %s" % JSON_GET_URL) -response = http.get(JSON_GET_URL) -print("-" * 40) - -print("JSON Response: ", response.json()) -print("-" * 40) -response.close() - -data = "31F" -print("POSTing data to {0}: {1}".format(JSON_POST_URL, data)) -response = http.post(JSON_POST_URL, data=data) -print("-" * 40) - -json_resp = response.json() -# Parse out the 'data' key from json_resp dict. -print("Data received from server:", json_resp["data"]) -print("-" * 40) - -json_data = {"Date": "July 25, 2019"} -print("POSTing data to {0}: {1}".format(JSON_POST_URL, json_data)) -response = http.post(JSON_POST_URL, json=json_data) -print("-" * 40) - -json_resp = response.json() -# Parse out the 'json' key from json_resp dict. -print("JSON Data received from server:", json_resp["json"]) -print("-" * 40) diff --git a/examples/requests_simpletest_ethernet.py b/examples/requests_simpletest_ethernet.py deleted file mode 100644 index 865d777..0000000 --- a/examples/requests_simpletest_ethernet.py +++ /dev/null @@ -1,112 +0,0 @@ -import board -import busio -from digitalio import DigitalInOut -from adafruit_wiznet5k.adafruit_wiznet5k import WIZNET5K -import adafruit_wiznet5k.adafruit_wiznet5k_socket as socket -import adafruit_requests as requests - -cs = DigitalInOut(board.D10) -spi_bus = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO) - -# Initialize ethernet interface with DHCP -eth = WIZNET5K(spi_bus, cs) - -# Initialize a requests object with a socket and ethernet interface -requests.set_socket(socket, eth) - -TEXT_URL = "http://wifitest.adafruit.com/testwifi/index.html" -JSON_GET_URL = "http://httpbin.org/get" -JSON_POST_URL = "http://httpbin.org/post" - -attempts = 3 # Number of attempts to retry each request -failure_count = 0 -response = None - -print("Fetching text from %s" % TEXT_URL) -while not response: - try: - response = requests.get(TEXT_URL) - failure_count = 0 - except AssertionError as error: - print("Request failed, retrying...\n", error) - failure_count += 1 - if failure_count >= attempts: - raise AssertionError( - "Failed to resolve hostname, \ - please check your router's DNS configuration." - ) from error - continue -print("-" * 40) - -print("Text Response: ", response.text) -print("-" * 40) -response.close() -response = None - -print("Fetching JSON data from %s" % JSON_GET_URL) -while not response: - try: - response = requests.get(JSON_GET_URL) - failure_count = 0 - except AssertionError as error: - print("Request failed, retrying...\n", error) - failure_count += 1 - if failure_count >= attempts: - raise AssertionError( - "Failed to resolve hostname, \ - please check your router's DNS configuration." - ) from error - continue -print("-" * 40) - -print("JSON Response: ", response.json()) -print("-" * 40) -response.close() -response = None - -data = "31F" -print("POSTing data to {0}: {1}".format(JSON_POST_URL, data)) -while not response: - try: - response = requests.post(JSON_POST_URL, data=data) - failure_count = 0 - except AssertionError as error: - print("Request failed, retrying...\n", error) - failure_count += 1 - if failure_count >= attempts: - raise AssertionError( - "Failed to resolve hostname, \ - please check your router's DNS configuration." - ) from error - continue -print("-" * 40) - -json_resp = response.json() -# Parse out the 'data' key from json_resp dict. -print("Data received from server:", json_resp["data"]) -print("-" * 40) -response.close() -response = None - -json_data = {"Date": "July 25, 2019"} -print("POSTing data to {0}: {1}".format(JSON_POST_URL, json_data)) -while not response: - try: - response = requests.post(JSON_POST_URL, json=json_data) - failure_count = 0 - except AssertionError as error: - print("Request failed, retrying...\n", error) - failure_count += 1 - if failure_count >= attempts: - raise AssertionError( - "Failed to resolve hostname, \ - please check your router's DNS configuration." - ) from error - continue -print("-" * 40) - -json_resp = response.json() -# Parse out the 'json' key from json_resp dict. -print("JSON Data received from server:", json_resp["json"]) -print("-" * 40) -response.close() diff --git a/examples/wifi/expanded/requests_wifi_adafruit_discord_active_online.py b/examples/wifi/expanded/requests_wifi_adafruit_discord_active_online.py new file mode 100644 index 0000000..8f6917c --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_adafruit_discord_active_online.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 8.2.x +"""Discord Active Online Shields.IO Example""" + +import os +import time + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Public API. No user or token required +# JSON web scrape from SHIELDS.IO +# Adafruit uses Shields.IO to see online users + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +# API Polling Rate +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +SLEEP_TIME = 900 + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +# Originally attempted to use SVG. Found JSON exists with same filename. +# https://img.shields.io/discord/327254708534116352.svg +ADA_DISCORD_JSON = "https://img.shields.io/discord/327254708534116352.json" + +while True: + # Connect to Wi-Fi + print("\nConnecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ Wifi!") + try: + print(" | Attempting to GET Adafruit Discord JSON!") + # Set debug to True for full JSON response. + DEBUG_RESPONSE = True + + try: + with requests.get(url=ADA_DISCORD_JSON) as shieldsio_response: + shieldsio_json = shieldsio_response.json() + except ConnectionError as e: + print(f"Connection Error: {e}") + print("Retrying in 10 seconds") + print(" | ✅ Adafruit Discord JSON!") + + if DEBUG_RESPONSE: + print(" | | Full API GET URL: ", ADA_DISCORD_JSON) + print(" | | JSON Dump: ", shieldsio_json) + + ada_users = shieldsio_json["value"] + ONLINE_STRING = " online" + REPLACE_WITH_NOTHING = "" + active_users = ada_users.replace(ONLINE_STRING, REPLACE_WITH_NOTHING) + print(f" | | Active Online Users: {active_users}") + + print("\nFinished!") + print(f"Board Uptime: {time.monotonic()}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + + except (ValueError, RuntimeError) as e: + print(f"Failed to get data, retrying\n {e}") + time.sleep(60) + break + time.sleep(SLEEP_TIME) diff --git a/examples/wifi/expanded/requests_wifi_api_discord.py b/examples/wifi/expanded/requests_wifi_api_discord.py new file mode 100644 index 0000000..f1b50bc --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_api_discord.py @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 8.2.x +"""Discord Web Scrape Example""" + +import os +import time + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Active Logged in User Account Required +# WEB SCRAPE authorization key required. Visit URL below. +# Learn how: https://github.com/lorenz234/Discord-Data-Scraping + +# Ensure this is in settings.toml +# DISCORD_AUTHORIZATION = "Approximately 70 Character Hash Here" + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") +discord_auth = os.getenv("DISCORD_AUTHORIZATION") + +# API Polling Rate +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +SLEEP_TIME = 900 + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +DISCORD_HEADER = {"Authorization": "" + discord_auth} +DISCORD_SOURCE = ( + "https://discord.com/api/v10/guilds/" + + "327254708534116352" # Adafruit Discord ID + + "/preview" +) + +while True: + # Connect to Wi-Fi + print("\nConnecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ Wifi!") + try: + print(" | Attempting to GET Discord JSON!") + # Set debug to True for full JSON response. + # WARNING: may include visible credentials + # MICROCONTROLLER WARNING: might crash by returning too much data + DEBUG_RESPONSE = False + + try: + with requests.get(url=DISCORD_SOURCE, headers=DISCORD_HEADER) as discord_response: + discord_json = discord_response.json() + except ConnectionError as e: + print(f"Connection Error: {e}") + print("Retrying in 10 seconds") + print(" | ✅ Discord JSON!") + + if DEBUG_RESPONSE: + print(f" | | Full API GET URL: {DISCORD_SOURCE}") + print(f" | | JSON Dump: {discord_json}") + + discord_name = discord_json["name"] + print(f" | | Name: {discord_name}") + + discord_description = discord_json["description"] + print(f" | | Description: {discord_description}") + + discord_all_members = discord_json["approximate_member_count"] + print(f" | | Members: {discord_all_members}") + + discord_all_members_online = discord_json["approximate_presence_count"] + print(f" | | Online: {discord_all_members_online}") + + print("\nFinished!") + print(f"Board Uptime: {time.monotonic()}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + + except (ValueError, RuntimeError) as e: + print(f"Failed to get data, retrying\n {e}") + time.sleep(60) + break + time.sleep(SLEEP_TIME) diff --git a/examples/wifi/expanded/requests_wifi_api_fitbit.py b/examples/wifi/expanded/requests_wifi_api_fitbit.py new file mode 100644 index 0000000..ee26a0a --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_api_fitbit.py @@ -0,0 +1,347 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 8.2.x +"""Fitbit API Example""" + +import os +import time + +import adafruit_connection_manager +import microcontroller +import wifi + +import adafruit_requests + +# --- Fitbit Developer Account & oAuth App Required: --- +# Required: Google Login (Fitbit owned by Google) & Fitbit Device +# Step 1: Register a personal app here: https://dev.fitbit.com +# Step 2: Use their Tutorial to get the Token and first Refresh Token +# Fitbit's Tutorial Step 4 is as far as you need to go. +# https://dev.fitbit.com/build/reference/web-api/troubleshooting-guide/oauth2-tutorial/ + +# Ensure these are in settings.toml +# Fitbit_ClientID = "YourAppClientID" +# Fitbit_Token = "Long 256 character string (SHA-256)" +# Fitbit_First_Refresh_Token = "64 character string" +# Fitbit_UserID = "UserID authorizing the ClientID" + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") +Fitbit_ClientID = os.getenv("FITBIT_CLIENTID") +Fitbit_Token = os.getenv("FITBIT_ACCESS_TOKEN") +Fitbit_First_Refresh_Token = os.getenv("FITBIT_FIRST_REFRESH_TOKEN") # overides nvm first run only +Fitbit_UserID = os.getenv("FITBIT_USERID") + +# Set debug to True for full INTRADAY JSON response. +# WARNING: may include visible credentials +# MICROCONTROLLER WARNING: might crash by returning too much data +DEBUG = False + +# Set debug to True for full DEVICE (Watch) JSON response. +# WARNING: may include visible credentials +# This will not return enough data to crash your device. +DEBUG_DEVICE = False + +# No data from midnight to 00:15 due to lack of 15 values. +# Debug midnight to display something else in this time frame. +MIDNIGHT_DEBUG = False + +# WARNING: Optional: Resets board nvm to factory default. Clean slate. +# Instructions will be printed to console while reset is True. +RESET_NVM = False # Set True once, then back to False +if RESET_NVM: + microcontroller.nvm[0:64] = bytearray(b"\x00" * 64) +# API Polling Rate +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +SLEEP_TIME = 900 + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +# Authenticates Client ID & SHA-256 Token to POST +FITBIT_OAUTH_HEADER = {"Content-Type": "application/x-www-form-urlencoded"} +FITBIT_OAUTH_TOKEN = "https://api.fitbit.com/oauth2/token" + +# Use to confirm first instance of NVM is the correct refresh token +FIRST_RUN = True +Refresh_Token = Fitbit_First_Refresh_Token +top_nvm = microcontroller.nvm[0:64].decode() +nvm_bytes = microcontroller.nvm[0:64] +top_nvm_3bytes = nvm_bytes[0:3] +if DEBUG: + print(f"Top NVM Length: {len(top_nvm)}") + print(f"Top NVM: {top_nvm}") + print(f"Top NVM bytes: {top_nvm_3bytes}") +if RESET_NVM: + microcontroller.nvm[0:64] = bytearray(b"\x00" * 64) + if top_nvm_3bytes == b"\x00\x00\x00": + print("TOP NVM IS BRAND NEW! WAITING FOR A FIRST TOKEN") + Fitbit_First_Refresh_Token = top_nvm + print(f"Top NVM RESET: {top_nvm}") # No token should appear + Refresh_Token = microcontroller.nvm[0:64].decode() + print(f"Refresh_Token Reset: {Refresh_Token}") # No token should appear +while True: + if not RESET_NVM: + # Connect to Wi-Fi + print("\n📡 Connecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ WiFi!") + + if top_nvm is not Refresh_Token and FIRST_RUN is False: + FIRST_RUN = False + Refresh_Token = microcontroller.nvm[0:64].decode() + print(" | INDEFINITE RUN -------") + if DEBUG: + print("Top NVM is Fitbit First Refresh Token") + # NVM 64 should match Current Refresh Token + print(f"NVM 64: {microcontroller.nvm[0:64].decode()}") + print(f"Current Refresh_Token: {Refresh_Token}") + if top_nvm != Fitbit_First_Refresh_Token and FIRST_RUN is True: + if top_nvm_3bytes == b"\x00\x00\x00": + print(" | TOP NVM IS BRAND NEW! WAITING FOR A FIRST TOKEN") + Refresh_Token = Fitbit_First_Refresh_Token + nvmtoken = b"" + Refresh_Token + microcontroller.nvm[0:64] = nvmtoken + else: + if DEBUG: + print(f"Top NVM: {top_nvm}") + print(f"First Refresh: {Refresh_Token}") + print(f"First Run: {FIRST_RUN}") + Refresh_Token = top_nvm + FIRST_RUN = False + print(" | MANUAL REBOOT TOKEN DIFFERENCE -------") + if DEBUG: + # NVM 64 should not match Current Refresh Token + print("Top NVM is NOT Fitbit First Refresh Token") + print(f"NVM 64: {microcontroller.nvm[0:64].decode()}") + print(f"Current Refresh_Token: {Refresh_Token}") + if top_nvm == Refresh_Token and FIRST_RUN is True: + if DEBUG: + print(f"Top NVM: {top_nvm}") + print(f"First Refresh: {Refresh_Token}") + print(f"First Run: {FIRST_RUN}") + Refresh_Token = Fitbit_First_Refresh_Token + nvmtoken = b"" + Refresh_Token + microcontroller.nvm[0:64] = nvmtoken + FIRST_RUN = False + print(" | FIRST RUN SETTINGS.TOML TOKEN-------") + if DEBUG: + # NVM 64 should match Current Refresh Token + print("Top NVM IS Fitbit First Refresh Token") + print(f"NVM 64: {microcontroller.nvm[0:64].decode()}") + print(f"Current Refresh_Token: {Refresh_Token}") + try: + if DEBUG: + print("\n-----Token Refresh POST Attempt -------") + FITBIT_OAUTH_REFRESH_TOKEN = ( + "&grant_type=refresh_token" + + "&client_id=" + + str(Fitbit_ClientID) + + "&refresh_token=" + + str(Refresh_Token) + ) + + # ------------------------- POST FOR REFRESH TOKEN -------------------- + print(" | Requesting authorization for next token") + if DEBUG: + print( + "FULL REFRESH TOKEN POST:" + f"{FITBIT_OAUTH_TOKEN}{FITBIT_OAUTH_REFRESH_TOKEN}" + ) + print(f"Current Refresh Token: {Refresh_Token}") + # TOKEN REFRESH POST + try: + with requests.post( + url=FITBIT_OAUTH_TOKEN, + data=FITBIT_OAUTH_REFRESH_TOKEN, + headers=FITBIT_OAUTH_HEADER, + ) as fitbit_oauth_refresh_POST: + fitbit_refresh_oauth_json = fitbit_oauth_refresh_POST.json() + except adafruit_requests.OutOfRetries as ex: + print(f"OutOfRetries: {ex}") + break + try: + fitbit_new_token = fitbit_refresh_oauth_json["access_token"] + if DEBUG: + print("Your Private SHA-256 Token: ", fitbit_new_token) + fitbit_access_token = fitbit_new_token # NEW FULL TOKEN + + # Overwrites Initial/Old Refresh Token with Next/New Refresh Token + fitbit_new_refesh_token = fitbit_refresh_oauth_json["refresh_token"] + Refresh_Token = fitbit_new_refesh_token + + fitbit_token_expiration = fitbit_refresh_oauth_json["expires_in"] + fitbit_scope = fitbit_refresh_oauth_json["scope"] + fitbit_token_type = fitbit_refresh_oauth_json["token_type"] + fitbit_user_id = fitbit_refresh_oauth_json["user_id"] + if DEBUG: + print("Next Refresh Token: ", Refresh_Token) + try: + # Stores Next token in NVM + nvmtoken = b"" + Refresh_Token + microcontroller.nvm[0:64] = nvmtoken + if DEBUG: + print(f"nvmtoken: {nvmtoken}") + # It's better to always have next token visible. + # You can manually set this token into settings.toml + print(f" | Next Token: {nvmtoken.decode()}") + print(" | 🔑 Next token written to NVM Successfully!") + except OSError as e: + print("OS Error:", e) + continue + if DEBUG: + print("Token Expires in: ", time_calc(fitbit_token_expiration)) + print("Scope: ", fitbit_scope) + print("Token Type: ", fitbit_token_type) + print("UserID: ", fitbit_user_id) + except KeyError as e: + print("Key Error:", e) + print("Expired token, invalid permission, or (key:value) pair error.") + time.sleep(SLEEP_TIME) + continue + # ----------------------------- GET DATA --------------------------------- + # Now that we have POST response with next refresh token we can GET for data + # 64-bit Refresh tokens will "keep alive" SHA-256 token indefinitely + # Fitbit main SHA-256 token expires in 8 hours unless refreshed! + # ------------------------------------------------------------------------ + DETAIL_LEVEL = "1min" # Supported: 1sec | 1min | 5min | 15min + REQUESTED_DATE = "today" # Date format yyyy-MM-dd or "today" + fitbit_header = { + "Authorization": "Bearer " + fitbit_access_token + "", + "Client-Id": "" + Fitbit_ClientID + "", + } + # Heart Intraday Scope + FITBIT_INTRADAY_SOURCE = ( + "https://api.fitbit.com/1/user/" + + Fitbit_UserID + + "/activities/heart/date/" + + REQUESTED_DATE + + "/1d/" + + DETAIL_LEVEL + + ".json" + ) + # Device Details + FITBIT_DEVICE_SOURCE = ( + "https://api.fitbit.com/1/user/" + Fitbit_UserID + "/devices.json" + ) + + print(" | Attempting to GET Fitbit JSON!") + FBIS = FITBIT_INTRADAY_SOURCE + FBH = fitbit_header + try: + with requests.get(url=FBIS, headers=FBH) as fitbit_get_response: + fitbit_json = fitbit_get_response.json() + except ConnectionError as e: + print("Connection Error:", e) + print("Retrying in 10 seconds") + print(" | ✅ Fitbit Intraday JSON!") + + if DEBUG: + print(f"Full API GET URL: {FBIS}") + print(f"Header: {fitbit_header}") + # This might crash your microcontroller. + # Commented out even in debug. Use only if absolutely necessary. + + # print(f"JSON Full Response: {fitbit_json}") + Intraday_Response = fitbit_json["activities-heart-intraday"]["dataset"] + # print(f"Intraday Full Response: {Intraday_Response}") + try: + # Fitbit's sync to mobile device & server every 15 minutes in chunks. + # Pointless to poll their API faster than 15 minute intervals. + activities_heart_value = fitbit_json["activities-heart-intraday"]["dataset"] + if MIDNIGHT_DEBUG: + RESPONSE_LENGTH = 0 + else: + RESPONSE_LENGTH = len(activities_heart_value) + if RESPONSE_LENGTH >= 15: + activities_timestamp = fitbit_json["activities-heart"][0]["dateTime"] + print(f" | | Fitbit Date: {activities_timestamp}") + if MIDNIGHT_DEBUG: + ACTIVITIES_LATEST_HEART_TIME = "00:05:00" + else: + ACTIVITIES_LATEST_HEART_TIME = fitbit_json["activities-heart-intraday"][ + "dataset" + ][RESPONSE_LENGTH - 1]["time"] + print(f" | | Fitbit Time: {ACTIVITIES_LATEST_HEART_TIME[0:-3]}") + print(f" | | Today's Logged Pulses: {RESPONSE_LENGTH}") + + # Each 1min heart rate is a 60 second average + LATEST_15_AVG = " | | Latest 15 Minute Averages: " + LATEST_15_VALUES = ", ".join( + str(activities_heart_value[i]["value"]) + for i in range(RESPONSE_LENGTH - 1, RESPONSE_LENGTH - 16, -1) + ) + print(f"{LATEST_15_AVG}{LATEST_15_VALUES}") + else: + print(" | Waiting for latest sync...") + print(" | ❌ Not enough values for today to display yet.") + except KeyError as keyerror: + print(f"Key Error: {keyerror}") + print( + "Too Many Requests, " + + "Expired token, " + + "invalid permission, " + + "or (key:value) pair error." + ) + time.sleep(60) + continue + # Getting Fitbit Device JSON (separate from intraday) + # Separate call for Watch Battery Percentage. + print(" | Attempting to GET Device JSON!") + FBDS = FITBIT_DEVICE_SOURCE + FBH = fitbit_header + try: + with requests.get(url=FBDS, headers=FBH) as fitbit_get_device_response: + fitbit_device_json = fitbit_get_device_response.json() + except ConnectionError as e: + print("Connection Error:", e) + print("Retrying in 10 seconds") + print(" | ✅ Fitbit Device JSON!") + + if DEBUG_DEVICE: + print(f"Full API GET URL: {FITBIT_DEVICE_SOURCE}") + print(f"Header: {fitbit_header}") + print(f"JSON Full Response: {fitbit_device_json}") + Device_Response = fitbit_device_json[1]["batteryLevel"] + print(f" | | Watch Battery %: {Device_Response}") + + print("\nFinished!") + print(f"Board Uptime: {time_calc(time.monotonic())}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + except (ValueError, RuntimeError) as e: + print("Failed to get data, retrying\n", e) + time.sleep(60) + continue + time.sleep(SLEEP_TIME) + else: + print("🚮 NVM Cleared!") + print( + "⚠️ Save your new access token & refresh token from " + "Fitbits Tutorial (Step 4) to settings.toml now." + ) + print( + "⚠️ If the script runs again" + "(due to settings.toml file save) while reset=True that's ok!" + ) + print("⚠️ Then set RESET_NVM back to False.") + break diff --git a/examples/wifi/expanded/requests_wifi_api_github.py b/examples/wifi/expanded/requests_wifi_api_github.py new file mode 100644 index 0000000..9d6770b --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_api_github.py @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 9.x +"""Github API Example""" + +import os +import time + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Github developer token required. +username = os.getenv("GITHUB_USERNAME") +token = os.getenv("GITHUB_TOKEN") + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +# API Polling Rate +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +SLEEP_TIME = 900 + +# Set debug to True for full JSON response. +# WARNING: may include visible credentials +DEBUG = False + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + +GITHUB_HEADER = {"Authorization": " token " + token} +GITHUB_SOURCE = "https://api.github.com/users/" + username + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +while True: + # Connect to Wi-Fi + print("\nConnecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ Wifi!") + + try: + print(" | Attempting to GET Github JSON!") + try: + with requests.get(url=GITHUB_SOURCE, headers=GITHUB_HEADER) as github_response: + github_json = github_response.json() + except ConnectionError as e: + print("Connection Error:", e) + print("Retrying in 10 seconds") + print(" | ✅ Github JSON!") + + github_joined = github_json["created_at"] + print(" | | Join Date: ", github_joined) + + github_id = github_json["id"] + print(" | | UserID: ", github_id) + + github_location = github_json["location"] + print(" | | Location: ", github_location) + + github_name = github_json["name"] + print(" | | Username: ", github_name) + + github_repos = github_json["public_repos"] + print(" | | Respositores: ", github_repos) + + github_followers = github_json["followers"] + print(" | | Followers: ", github_followers) + github_bio = github_json["bio"] + print(" | | Bio: ", github_bio) + + if DEBUG: + print("Full API GET URL: ", GITHUB_SOURCE) + print(github_json) + + print("\nFinished!") + print(f"Board Uptime: {time_calc(time.monotonic())}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + + except (ValueError, RuntimeError) as e: + print(f"Failed to get data, retrying\n {e}") + time.sleep(60) + break + time.sleep(SLEEP_TIME) diff --git a/examples/wifi/expanded/requests_wifi_api_mastodon.py b/examples/wifi/expanded/requests_wifi_api_mastodon.py new file mode 100644 index 0000000..bcbf211 --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_api_mastodon.py @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 8.2.x +"""Mastodon API Example""" + +import os +import time + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Mastodon V1 API - Public access (no dev creds or app required) +# Visit https://docs.joinmastodon.org/client/public/ for API docs +# For finding your Mastodon numerical UserID +# Example: https://mastodon.YOURSERVER/api/v1/accounts/lookup?acct=YourUserName + +MASTODON_SERVER = "mastodon.social" # Set server instance +MASTODON_USERID = "000000000000000000" # Numerical UserID you want endpoints from +# Test in browser first, this will pull up a JSON webpage +# https://mastodon.YOURSERVER/api/v1/accounts/YOURUSERIDHERE/statuses?limit=1 + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +# API Polling Rate +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +SLEEP_TIME = 900 + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +# Publicly available data no header required +MAST_SOURCE = ( + "https://" + MASTODON_SERVER + "/api/v1/accounts/" + MASTODON_USERID + "/statuses?limit=1" +) + +while True: + # Connect to Wi-Fi + print("\nConnecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ Wifi!") + try: + # Print Request to Serial + print(" | Attempting to GET MASTODON JSON!") + + # Set debug to True for full JSON response. + # WARNING: may include visible credentials + # MICROCONTROLLER WARNING: might crash by returning too much data + DEBUG_RESPONSE = False + + try: + with requests.get(url=MAST_SOURCE) as mastodon_response: + mastodon_json = mastodon_response.json() + except ConnectionError as e: + print(f"Connection Error: {e}") + print("Retrying in 10 seconds") + mastodon_json = mastodon_json[0] + print(" | ✅ Mastodon JSON!") + + if DEBUG_RESPONSE: + print(" | | Full API GET URL: ", MAST_SOURCE) + mastodon_userid = mastodon_json["account"]["id"] + print(f" | | User ID: {mastodon_userid}") + print(mastodon_json) + + mastodon_name = mastodon_json["account"]["display_name"] + print(f" | | Name: {mastodon_name}") + mastodon_join_date = mastodon_json["account"]["created_at"] + print(f" | | Member Since: {mastodon_join_date}") + mastodon_follower_count = mastodon_json["account"]["followers_count"] + print(f" | | Followers: {mastodon_follower_count}") + mastodon_following_count = mastodon_json["account"]["following_count"] + print(f" | | Following: {mastodon_following_count}") + mastodon_toot_count = mastodon_json["account"]["statuses_count"] + print(f" | | Toots: {mastodon_toot_count}") + mastodon_last_toot = mastodon_json["account"]["last_status_at"] + print(f" | | Last Toot: {mastodon_last_toot}") + mastodon_bio = mastodon_json["account"]["note"] + print(f" | | Bio: {mastodon_bio[3:-4]}") # removes included html "

&

" + + print("\nFinished!") + print(f"Board Uptime: {time.monotonic()}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + + except (ValueError, RuntimeError) as e: + print(f"Failed to get data, retrying\n {e}") + time.sleep(60) + break + time.sleep(SLEEP_TIME) diff --git a/examples/wifi/expanded/requests_wifi_api_openskynetwork_private.py b/examples/wifi/expanded/requests_wifi_api_openskynetwork_private.py new file mode 100644 index 0000000..0608b30 --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_api_openskynetwork_private.py @@ -0,0 +1,186 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 9.x +"""OpenSky-Network.org Single Flight Private API Example""" + +import binascii +import os +import time + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# OpenSky-Network.org Login required for this API +# REST API: https://openskynetwork.github.io/opensky-api/rest.html +# All active flights JSON: https://opensky-network.org/api/states/all # PICK ONE! :) +# JSON order: transponder, callsign, country +# ACTIVE transpondes only, for multiple "c822af&icao24=cb3993&icao24=c63923" +TRANSPONDER = "4b1812" + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") +osnusername = os.getenv("OSN_USERNAME") # Website Credentials +osnpassword = os.getenv("OSN_PASSWORD") # Website Credentials + +# API Polling Rate +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +# OpenSky-Networks IP bans for too many requests, check rate limit. +# https://openskynetwork.github.io/opensky-api/rest.html#limitations +SLEEP_TIME = 1800 + +# Set debug to True for full JSON response. +# WARNING: makes credentials visible +DEBUG = False + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + +# -- Base64 Conversion -- +OSN_CREDENTIALS = str(osnusername) + ":" + str(osnpassword) +# base64 encode and strip appended \n from bytearray +OSN_CREDENTIALS_B = binascii.b2a_base64(OSN_CREDENTIALS.encode()).strip() +BASE64_STRING = OSN_CREDENTIALS_B.decode() # bytearray + + +if DEBUG: + print("Base64 ByteArray: ", BASE64_STRING) + +# Requests URL - icao24 is their endpoint required for a transponder +# example https://opensky-network.org/api/states/all?icao24=a808c5 +# OSN private: requires your website username:password to be base64 encoded +OPENSKY_HEADER = {"Authorization": "Basic " + BASE64_STRING} +OPENSKY_SOURCE = "https://opensky-network.org/api/states/all?" + "icao24=" + TRANSPONDER + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +def _format_datetime(datetime): + return ( + f"{datetime.tm_mon:02}/{datetime.tm_mday:02}/{datetime.tm_year} " + f"{datetime.tm_hour:02}:{datetime.tm_min:02}:{datetime.tm_sec:02}" + ) + + +while True: + # Connect to Wi-Fi + print("\nConnecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ Wifi!") + + try: + print(" | Attempting to GET OpenSky-Network Single Private Flight JSON!") + print(" | Website Credentials Required! Allows more daily calls than Public.") + try: + with requests.get(url=OPENSKY_SOURCE, headers=OPENSKY_HEADER) as opensky_response: + opensky_json = opensky_response.json() + except ConnectionError as e: + print("Connection Error:", e) + print("Retrying in 10 seconds") + + print(" | ✅ OpenSky-Network JSON!") + + if DEBUG: + print("Full API GET URL: ", OPENSKY_SOURCE) + print("Full API GET Header: ", OPENSKY_HEADER) + print(opensky_json) + + # ERROR MESSAGE RESPONSES + if "timestamp" in opensky_json: + osn_timestamp = opensky_json["timestamp"] + print(f"❌ Timestamp: {osn_timestamp}") + + if "message" in opensky_json: + osn_message = opensky_json["message"] + print(f"❌ Message: {osn_message}") + + if "error" in opensky_json: + osn_error = opensky_json["error"] + print(f"❌ Error: {osn_error}") + + if "path" in opensky_json: + osn_path = opensky_json["path"] + print(f"❌ Path: {osn_path}") + + if "status" in opensky_json: + osn_status = opensky_json["status"] + print(f"❌ Status: {osn_status}") + + # Current flight data for single callsign (right now) + osn_single_flight_data = opensky_json["states"] + + if osn_single_flight_data is not None: + if DEBUG: + print(f" | | Single Private Flight Data: {osn_single_flight_data}") + + last_contact = opensky_json["states"][0][4] + # print(f" | | Last Contact Unix Time: {last_contact}") + lc_struct_time = time.localtime(last_contact) + lc_readable_time = f"{_format_datetime(lc_struct_time)}" + print(f" | | Last Contact: {lc_readable_time}") + + flight_transponder = opensky_json["states"][0][0] + print(f" | | Transponder: {flight_transponder}") + + callsign = opensky_json["states"][0][1] + print(f" | | Callsign: {callsign}") + + squawk = opensky_json["states"][0][14] + print(f" | | Squawk: {squawk}") + + country = opensky_json["states"][0][2] + print(f" | | Origin: {country}") + + longitude = opensky_json["states"][0][5] + print(f" | | Longitude: {longitude}") + + latitude = opensky_json["states"][0][6] + print(f" | | Latitude: {latitude}") + + # Return Air Flight data if not on ground + on_ground = opensky_json["states"][0][8] + if on_ground is True: + print(f" | | On Ground: {on_ground}") + else: + altitude = opensky_json["states"][0][7] + print(f" | | Barometric Altitude: {altitude}") + + velocity = opensky_json["states"][0][9] + if velocity != "null": + print(f" | | Velocity: {velocity}") + + vertical_rate = opensky_json["states"][0][11] + if vertical_rate != "null": + print(f" | | Vertical Rate: {vertical_rate}") + + else: + print(" | | ❌ Flight has no active data or you're polling too fast.") + + print("\nFinished!") + print(f"Board Uptime: {time_calc(time.monotonic())}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + + except (ValueError, RuntimeError) as e: + print(f"Failed to get data, retrying\n {e}") + time.sleep(60) + break + time.sleep(SLEEP_TIME) diff --git a/examples/wifi/expanded/requests_wifi_api_openskynetwork_private_area.py b/examples/wifi/expanded/requests_wifi_api_openskynetwork_private_area.py new file mode 100644 index 0000000..243c859 --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_api_openskynetwork_private_area.py @@ -0,0 +1,191 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 8.2.x +"""OpenSky-Network.org Private Area API Example""" + +import binascii +import os +import time + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# OpenSky-Network.org Website Login required for this API +# Increased call limit vs Public. +# REST API: https://openskynetwork.github.io/opensky-api/rest.html +# Retrieves all traffic within a geographic area (Orlando example) +LATMIN = "27.22" # east bounding box +LATMAX = "28.8" # west bounding box +LONMIN = "-81.46" # north bounding box +LONMAX = "-80.40" # south bounding box + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") +osnusername = os.getenv("OSN_USERNAME") # Website Credentials +osnpassword = os.getenv("OSN_PASSWORD") # Website Credentials + +# API Polling Rate +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +# OpenSky-Networks IP bans for too many requests, check rate limit. +# https://openskynetwork.github.io/opensky-api/rest.html#limitations +SLEEP_TIME = 1800 + +# Set debug to True for full JSON response. +# WARNING: makes credentials visible. based on how many flights +# in your area, full response could crash microcontroller +DEBUG = False + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + +# -- Base64 Conversion -- +OSN_CREDENTIALS = str(osnusername) + ":" + str(osnpassword) +# base64 encode and strip appended \n from bytearray +OSN_CREDENTIALS_B = binascii.b2a_base64(OSN_CREDENTIALS.encode()).strip() +BASE64_STRING = OSN_CREDENTIALS_B.decode() # bytearray + +if DEBUG: + print("Base64 ByteArray: ", BASE64_STRING) + +# Area requires OpenSky-Network.org username:password to be base64 encoded +OSN_HEADER = {"Authorization": "Basic " + BASE64_STRING} + +# Example request of all traffic over Florida. +# Geographic areas calls cost less against the limit. +# https://opensky-network.org/api/states/all?lamin=25.21&lomin=-84.36&lamax=30.0&lomax=-78.40 +OPENSKY_SOURCE = ( + "https://opensky-network.org/api/states/all?" + + "lamin=" + + LATMIN + + "&lomin=" + + LONMIN + + "&lamax=" + + LATMAX + + "&lomax=" + + LONMAX +) + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +def _format_datetime(datetime): + return ( + f"{datetime.tm_mon:02}/{datetime.tm_mday:02}/{datetime.tm_year} " + f"{datetime.tm_hour:02}:{datetime.tm_min:02}:{datetime.tm_sec:02}" + ) + + +while True: + # Connect to Wi-Fi + print("\nConnecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ Wifi!") + + try: + print(" | Attempting to GET OpenSky-Network Area Flights JSON!") + try: + with requests.get(url=OPENSKY_SOURCE, headers=OSN_HEADER) as opensky_response: + opensky_json = opensky_response.json() + except ConnectionError as e: + print("Connection Error:", e) + print("Retrying in 10 seconds") + + print(" | ✅ OpenSky-Network JSON!") + + if DEBUG: + print("Full API GET URL: ", OPENSKY_SOURCE) + print(opensky_json) + + # ERROR MESSAGE RESPONSES + if "timestamp" in opensky_json: + osn_timestamp = opensky_json["timestamp"] + print(f"❌ Timestamp: {osn_timestamp}") + + if "message" in opensky_json: + osn_message = opensky_json["message"] + print(f"❌ Message: {osn_message}") + + if "error" in opensky_json: + osn_error = opensky_json["error"] + print(f"❌ Error: {osn_error}") + + if "path" in opensky_json: + osn_path = opensky_json["path"] + print(f"❌ Path: {osn_path}") + + if "status" in opensky_json: + osn_status = opensky_json["status"] + print(f"❌ Status: {osn_status}") + + # Current flight data for single callsign (right now) + osn_all_flights = opensky_json["states"] + + if osn_all_flights is not None: + if DEBUG: + print(f" | | Area Flights Full Response: {osn_all_flights}") + + osn_time = opensky_json["time"] + # print(f" | | Last Contact Unix Time: {osn_time}") + osn_struct_time = time.localtime(osn_time) + osn_readable_time = f"{_format_datetime(osn_struct_time)}" + print(f" | | Timestamp: {osn_readable_time}") + + if osn_all_flights is not None: + # print("Flight Data: ", osn_all_flights) + for flights in osn_all_flights: + osn_t = f" | | Trans:{flights[0]} " + osn_c = f"Sign:{flights[1]}" + osn_o = f"Origin:{flights[2]} " + osn_tm = f"Time:{flights[3]} " + osn_l = f"Last:{flights[4]} " + osn_lo = f"Lon:{flights[5]} " + osn_la = f"Lat:{flights[6]} " + osn_ba = f"BaroAlt:{flights[7]} " + osn_g = f"Ground:{flights[8]} " + osn_v = f"Vel:{flights[9]} " + osn_h = f"Head:{flights[10]} " + osn_vr = f"VertRate:{flights[11]} " + osn_s = f"Sens:{flights[12]} " + osn_ga = f"GeoAlt:{flights[13]} " + osn_sq = f"Squawk:{flights[14]} " + osn_pr = f"Task:{flights[15]} " + osn_ps = f"PosSys:{flights[16]} " + osn_ca = f"Cat:{flights[16]} " + # This is just because pylint complains about long lines + string1 = f"{osn_t}{osn_c}{osn_o}{osn_tm}{osn_l}{osn_lo}" + string2 = f"{osn_la}{osn_ba}{osn_g}{osn_v}{osn_h}{osn_vr}" + string3 = f"{osn_s}{osn_ga}{osn_sq}{osn_pr}{osn_ps}{osn_ca}" + print(f"{string1}{string2}{string3}") + + else: + print(" | | ❌ Area has no active data or you're polling too fast.") + + print("\nFinished!") + print(f"Board Uptime: {time_calc(time.monotonic())}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + + except (ValueError, RuntimeError) as e: + print(f"Failed to get data, retrying\n {e}") + time.sleep(60) + break + time.sleep(SLEEP_TIME) diff --git a/examples/wifi/expanded/requests_wifi_api_openskynetwork_public.py b/examples/wifi/expanded/requests_wifi_api_openskynetwork_public.py new file mode 100644 index 0000000..f89f88d --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_api_openskynetwork_public.py @@ -0,0 +1,170 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 9.x +"""OpenSky-Network.org Single Flight Public API Example""" + +import os +import time + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# No login necessary for Public API. Drastically reduced daily limit vs Private +# OpenSky-Networks.org REST API: https://openskynetwork.github.io/opensky-api/rest.html +# All active flights JSON: https://opensky-network.org/api/states/all PICK ONE! +# JSON order: transponder, callsign, country +# ACTIVE transpondes only, for multiple "c822af&icao24=cb3993&icao24=c63923" +TRANSPONDER = "88044d" + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +# API Polling Rate +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +# OpenSky-Networks IP bans for too many requests, check rate limit. +# https://openskynetwork.github.io/opensky-api/rest.html#limitations +SLEEP_TIME = 1800 + +# Set debug to True for full JSON response. +# WARNING: makes credentials visible +DEBUG = False + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + +# Requests URL - icao24 is their endpoint required for a transponder +# example https://opensky-network.org/api/states/all?icao24=a808c5 +OPENSKY_SOURCE = "https://opensky-network.org/api/states/all?" + "icao24=" + TRANSPONDER + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +def _format_datetime(datetime): + return ( + f"{datetime.tm_mon:02}/{datetime.tm_mday:02}/{datetime.tm_year} " + f"{datetime.tm_hour:02}:{datetime.tm_min:02}:{datetime.tm_sec:02}" + ) + + +while True: + # Connect to Wi-Fi + print("\nConnecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ Wifi!") + + try: + print(" | Attempting to GET OpenSky-Network Single Public Flight JSON!") + print(" | Website Credentials NOT Required! Less daily calls than Private.") + try: + with requests.get(url=OPENSKY_SOURCE) as opensky_response: + opensky_json = opensky_response.json() + except ConnectionError as e: + print("Connection Error:", e) + print("Retrying in 10 seconds") + + print(" | ✅ OpenSky-Network Public JSON!") + + if DEBUG: + print("Full API GET URL: ", OPENSKY_SOURCE) + print(opensky_json) + + # ERROR MESSAGE RESPONSES + if "timestamp" in opensky_json: + osn_timestamp = opensky_json["timestamp"] + print(f"❌ Timestamp: {osn_timestamp}") + + if "message" in opensky_json: + osn_message = opensky_json["message"] + print(f"❌ Message: {osn_message}") + + if "error" in opensky_json: + osn_error = opensky_json["error"] + print(f"❌ Error: {osn_error}") + + if "path" in opensky_json: + osn_path = opensky_json["path"] + print(f"❌ Path: {osn_path}") + + if "status" in opensky_json: + osn_status = opensky_json["status"] + print(f"❌ Status: {osn_status}") + + # Current flight data for single callsign (right now) + osn_single_flight_data = opensky_json["states"] + + if osn_single_flight_data is not None: + if DEBUG: + print(f" | | Single Flight Public Data: {osn_single_flight_data}") + + last_contact = opensky_json["states"][0][4] + # print(f" | | Last Contact Unix Time: {last_contact}") + lc_struct_time = time.localtime(last_contact) + lc_readable_time = f"{_format_datetime(lc_struct_time)}" + print(f" | | Last Contact: {lc_readable_time}") + + flight_transponder = opensky_json["states"][0][0] + print(f" | | Transponder: {flight_transponder}") + + callsign = opensky_json["states"][0][1] + print(f" | | Callsign: {callsign}") + + squawk = opensky_json["states"][0][14] + print(f" | | Squawk: {squawk}") + + country = opensky_json["states"][0][2] + print(f" | | Origin: {country}") + + longitude = opensky_json["states"][0][5] + print(f" | | Longitude: {longitude}") + + latitude = opensky_json["states"][0][6] + print(f" | | Latitude: {latitude}") + + # Return Air Flight data if not on ground + on_ground = opensky_json["states"][0][8] + if on_ground is True: + print(f" | | On Ground: {on_ground}") + else: + altitude = opensky_json["states"][0][7] + print(f" | | Barometric Altitude: {altitude}") + + velocity = opensky_json["states"][0][9] + if velocity != "null": + print(f" | | Velocity: {velocity}") + + vertical_rate = opensky_json["states"][0][11] + if vertical_rate != "null": + print(f" | | Vertical Rate: {vertical_rate}") + else: + print("This flight has no active data or you're polling too fast.") + print("Public Limits: 10 second max poll & 400 weighted calls daily") + + print("\nFinished!") + print(f"Board Uptime: {time_calc(time.monotonic())}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + + except (ValueError, RuntimeError) as e: + print(f"Failed to get data, retrying\n {e}") + time.sleep(60) + break + time.sleep(SLEEP_TIME) diff --git a/examples/wifi/expanded/requests_wifi_api_premiereleague.py b/examples/wifi/expanded/requests_wifi_api_premiereleague.py new file mode 100644 index 0000000..5f61e1a --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_api_premiereleague.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 8.2.x +"""Premiere League Total Players API Example""" + +import os +import time + +import adafruit_connection_manager +import adafruit_json_stream as json_stream +import wifi + +import adafruit_requests + +# Public API. No user or token required + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +# API Polling Rate +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +SLEEP_TIME = 900 + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + +# Publicly available data no header required +PREMIERE_LEAGUE_SOURCE = "https://fantasy.premierleague.com/api/bootstrap-static/" + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +while True: + # Connect to Wi-Fi + print("\nConnecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ Wifi!") + + try: + print(" | Attempting to GET Premiere League JSON!") + + # Set debug to True for full JSON response. + # WARNING: may include visible credentials + # MICROCONTROLLER WARNING: might crash by returning too much data + DEBUG_RESPONSE = False + + try: + with requests.get(url=PREMIERE_LEAGUE_SOURCE) as PREMIERE_LEAGUE_RESPONSE: + pl_json = json_stream.load(PREMIERE_LEAGUE_RESPONSE.iter_content(32)) + except ConnectionError as e: + print(f"Connection Error: {e}") + print("Retrying in 10 seconds") + print(" | ✅ Premiere League JSON!") + + print(f" | Total Premiere League Players: {pl_json['total_players']}") + + print("\nFinished!") + print(f"Board Uptime: {time.monotonic()}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + + except (ValueError, RuntimeError) as e: + print(f"Failed to get data, retrying\n {e}") + time.sleep(60) + break + time.sleep(SLEEP_TIME) diff --git a/examples/wifi/expanded/requests_wifi_api_queuetimes.py b/examples/wifi/expanded/requests_wifi_api_queuetimes.py new file mode 100644 index 0000000..b6fecaf --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_api_queuetimes.py @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 9.x +"""Queue-Times.com API Example""" + +import os + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + +# Time between API refreshes +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +SLEEP_TIME = 300 + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +# Publicly Open API (no credentials required) +QTIMES_SOURCE = "https://queue-times.com/parks/16/queue_times.json" + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +qtimes_json = {} + +# Connect to Wi-Fi +print("\n===============================") +print("Connecting to WiFi...") +while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") +print("✅ WiFi!") + +try: + with requests.get(url=QTIMES_SOURCE) as qtimes_response: + qtimes_json = qtimes_response.json() + + print(" | ✅ Queue-Times JSON\n") + DEBUG_QTIMES = False + if DEBUG_QTIMES: + print("Full API GET URL: ", QTIMES_SOURCE) + print(qtimes_json) + + # Poll Once and end script + for land in qtimes_json["lands"]: + qtimes_lands = str(land["name"]) + print(f" | Land: {qtimes_lands}") + + # Loop through each ride in the land + for ride in land["rides"]: + qtimes_rides = str(ride["name"]) + qtimes_queuetime = str(ride["wait_time"]) + qtimes_isopen = str(ride["is_open"]) + + print(f" | | Ride: {qtimes_rides}") + print(f" | | Queue Time: {qtimes_queuetime} Minutes") + if qtimes_isopen == "False": + print(" | | Status: Closed\n") + elif qtimes_isopen == "True": + print(" | | Status: Open\n") + else: + print(" | | Status: Unknown\n") + + +except ConnectionError as e: + print("Connection Error:", e) diff --git a/examples/wifi/expanded/requests_wifi_api_rocketlaunch_live.py b/examples/wifi/expanded/requests_wifi_api_rocketlaunch_live.py new file mode 100644 index 0000000..67034bb --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_api_rocketlaunch_live.py @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 9.0 +"""RocketLaunch.Live API Example""" + +import os +import time + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Time between API refreshes +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +SLEEP_TIME = 43200 + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +# Publicly available data no header required +# The number at the end is the amount of launches (max 5 free api) +ROCKETLAUNCH_SOURCE = "https://fdo.rocketlaunch.live/json/launches/next/1" + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + +while True: + # Connect to Wi-Fi + print("\n===============================") + print("Connecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ Wifi!") + try: + # Print Request to Serial + print(" | Attempting to GET RocketLaunch.Live JSON!") + time.sleep(2) + debug_rocketlaunch_full_response = False + + try: + with requests.get(url=ROCKETLAUNCH_SOURCE) as rocketlaunch_response: + rocketlaunch_json = rocketlaunch_response.json() + except ConnectionError as e: + print("Connection Error:", e) + print("Retrying in 10 seconds") + print(" | ✅ RocketLaunch.Live JSON!") + + if debug_rocketlaunch_full_response: + print("Full API GET URL: ", ROCKETLAUNCH_SOURCE) + print(rocketlaunch_json) + + # JSON Endpoints + RLFN = str(rocketlaunch_json["result"][0]["name"]) + RLWO = str(rocketlaunch_json["result"][0]["win_open"]) + TZERO = str(rocketlaunch_json["result"][0]["t0"]) + RLWC = str(rocketlaunch_json["result"][0]["win_close"]) + RLP = str(rocketlaunch_json["result"][0]["provider"]["name"]) + RLVN = str(rocketlaunch_json["result"][0]["vehicle"]["name"]) + RLPN = str(rocketlaunch_json["result"][0]["pad"]["name"]) + RLLS = str(rocketlaunch_json["result"][0]["pad"]["location"]["name"]) + RLLD = str(rocketlaunch_json["result"][0]["launch_description"]) + RLM = str(rocketlaunch_json["result"][0]["mission_description"]) + RLDATE = str(rocketlaunch_json["result"][0]["date_str"]) + + # Print to serial & display label if endpoint not "None" + if RLDATE != "None": + print(f" | | Date: {RLDATE}") + if RLFN != "None": + print(f" | | Flight: {RLFN}") + if RLP != "None": + print(f" | | Provider: {RLP}") + if RLVN != "None": + print(f" | | Vehicle: {RLVN}") + + # Launch time can sometimes be Window Open to Close, T-Zero, or weird combination. + # Should obviously be standardized but they're not input that way. + # Have to account for every combination of 3 conditions. + # T-Zero Launch Time Conditionals + if RLWO == "None" and TZERO != "None" and RLWC != "None": + print(f" | | Window: {TZERO} | {RLWC}") + elif RLWO != "None" and TZERO != "None" and RLWC == "None": + print(f" | | Window: {RLWO} | {TZERO}") + elif RLWO != "None" and TZERO == "None" and RLWC != "None": + print(f" | | Window: {RLWO} | {RLWC}") + elif RLWO != "None" and TZERO != "None" and RLWC != "None": + print(f" | | Window: {RLWO} | {TZERO} | {RLWC}") + elif RLWO == "None" and TZERO != "None" and RLWC == "None": + print(f" | | Window: {TZERO}") + elif RLWO != "None" and TZERO == "None" and RLWC == "None": + print(f" | | Window: {RLWO}") + elif RLWO == "None" and TZERO == "None" and RLWC != "None": + print(f" | | Window: {RLWC}") + + if RLLS != "None": + print(f" | | Site: {RLLS}") + if RLPN != "None": + print(f" | | Pad: {RLPN}") + if RLLD != "None": + print(f" | | Description: {RLLD}") + if RLM != "None": + print(f" | | Mission: {RLM}") + + print("\nFinished!") + print(f"Board Uptime: {time_calc(time.monotonic())}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + + except (ValueError, RuntimeError) as e: + print("Failed to get data, retrying\n", e) + time.sleep(60) + break + time.sleep(SLEEP_TIME) diff --git a/examples/wifi/expanded/requests_wifi_api_steam.py b/examples/wifi/expanded/requests_wifi_api_steam.py new file mode 100644 index 0000000..42d1e31 --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_api_steam.py @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 8.2.x +"""Steam API Get Owned Games Example""" + +import os +import time + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Steam API Docs: https://steamcommunity.com/dev +# Steam API Key: https://steamcommunity.com/dev/apikey +# Numerical Steam ID: Visit https://store.steampowered.com/account/ +# Your account name will be in big bold letters. +# Your numerical STEAM ID will be below in a very small font. + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") +# Requires Steam Developer API key +steam_usernumber = os.getenv("STEAM_ID") +steam_apikey = os.getenv("STEAM_API_KEY") + +# API Polling Rate +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +SLEEP_TIME = 3600 + +# Set debug to True for full JSON response. +# WARNING: Steam's full response will overload most microcontrollers +# SET TO TRUE IF YOU FEEL BRAVE =) +DEBUG = False + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + +# Deconstruct URL (pylint hates long lines) +# http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/ +# ?key=XXXXXXXXXXXXXXXXXXXXX&include_played_free_games=1&steamid=XXXXXXXXXXXXXXXX&format=json +STEAM_SOURCE = ( + "http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?" + + "key=" + + steam_apikey + + "&include_played_free_games=1" + + "&steamid=" + + steam_usernumber + + "&format=json" +) + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +def _format_datetime(datetime): + """F-String formatted struct time conversion""" + return ( + f"{datetime.tm_mon:02}/" + + f"{datetime.tm_mday:02}/" + + f"{datetime.tm_year:02} " + + f"{datetime.tm_hour:02}:" + + f"{datetime.tm_min:02}:" + + f"{datetime.tm_sec:02}" + ) + + +while True: + # Connect to Wi-Fi + print("\nConnecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ Wifi!") + + try: + print(" | Attempting to GET Steam API JSON!") + try: + with requests.get(url=STEAM_SOURCE) as steam_response: + steam_json = steam_response.json() + except ConnectionError as e: + print("Connection Error:", e) + print("Retrying in 10 seconds") + + print(" | ✅ Steam JSON!") + + if DEBUG: + print("Full API GET URL: ", STEAM_SOURCE) + print(steam_json) + + game_count = steam_json["response"]["game_count"] + print(f" | | Total Games: {game_count}") + TOTAL_MINUTES = 0 + + for game in steam_json["response"]["games"]: + TOTAL_MINUTES += game["playtime_forever"] + total_hours = TOTAL_MINUTES / 60 + total_days = TOTAL_MINUTES / 60 / 24 + total_years = TOTAL_MINUTES / 60 / 24 / 365 + print(f" | | Total Hours: {total_hours}") + print(f" | | Total Days: {total_days}") + print(f" | | Total Years: {total_years:.2f}") + + print("\nFinished!") + print(f"Board Uptime: {time_calc(time.monotonic())}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + + except (ValueError, RuntimeError) as e: + print(f"Failed to get data, retrying\n {e}") + time.sleep(60) + break + time.sleep(SLEEP_TIME) diff --git a/examples/wifi/expanded/requests_wifi_api_twitch.py b/examples/wifi/expanded/requests_wifi_api_twitch.py new file mode 100644 index 0000000..15cbcc0 --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_api_twitch.py @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 8.2.x +"""Twitch API Example""" + +import os +import time + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Twitch Developer Account & oauth App Required: +# Visit https://dev.twitch.tv/console to create an app +# Ensure these are in settings.toml +# TWITCH_CLIENT_ID = "Your Developer APP ID Here" +# TWITCH_CLIENT_SECRET = "APP ID secret here" +# TWITCH_USER_ID = "Your Twitch UserID here" + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") +TWITCH_CID = os.getenv("TWITCH_CLIENT_ID") +TWITCH_CS = os.getenv("TWITCH_CLIENT_SECRET") +# For finding your Twitch User ID +# https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/ +TWITCH_UID = os.getenv("TWITCH_USER_ID") + +# API Polling Rate +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +SLEEP_TIME = 900 + +# Set DEBUG to True for full JSON response. +# STREAMER WARNING: Credentials will be viewable +DEBUG = False + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +def _format_datetime(datetime): + """F-String formatted struct time conversion""" + return ( + f"{datetime.tm_mon:02}/" + + f"{datetime.tm_mday:02}/" + + f"{datetime.tm_year:02} " + + f"{datetime.tm_hour:02}:" + + f"{datetime.tm_min:02}:" + + f"{datetime.tm_sec:02}" + ) + + +# First we use Client ID & Client Secret to create a token with POST +# No user interaction is required for this type of scope (implicit grant flow) +twitch_0auth_header = {"Content-Type": "application/x-www-form-urlencoded"} +TWITCH_0AUTH_TOKEN = "https://id.twitch.tv/oauth2/token" + +while True: + # Connect to Wi-Fi + print("\nConnecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ Wifi!") + + try: + # ------------- POST FOR BEARER TOKEN ----------------- + print(" | Attempting Bearer Token Request!") + if DEBUG: + print(f"Full API GET URL: {TWITCH_0AUTH_TOKEN}") + twitch_0auth_data = ( + "&client_id=" + + TWITCH_CID + + "&client_secret=" + + TWITCH_CS + + "&grant_type=client_credentials" + ) + + # POST REQUEST + try: + with requests.post( + url=TWITCH_0AUTH_TOKEN, + data=twitch_0auth_data, + headers=twitch_0auth_header, + ) as twitch_0auth_response: + twitch_0auth_json = twitch_0auth_response.json() + twitch_access_token = twitch_0auth_json["access_token"] + except ConnectionError as e: + print(f"Connection Error: {e}") + print("Retrying in 10 seconds") + print(" | 🔑 Token Authorized!") + + # STREAMER WARNING: your client secret will be viewable + if DEBUG: + print(f"JSON Dump: {twitch_0auth_json}") + print(f"Header: {twitch_0auth_header}") + print(f"Access Token: {twitch_access_token}") + twitch_token_type = twitch_0auth_json["token_type"] + print(f"Token Type: {twitch_token_type}") + + twitch_token_expiration = twitch_0auth_json["expires_in"] + print(f" | Token Expires in: {time_calc(twitch_token_expiration)}") + + # ----------------------------- GET DATA -------------------- + # Bearer token is refreshed every time script runs :) + # Twitch sets token expiration to about 64 days + # Helix is the name of the current Twitch API + # Now that we have POST bearer token we can do a GET for data + # ----------------------------------------------------------- + twitch_header = { + "Authorization": "Bearer " + twitch_access_token + "", + "Client-Id": "" + TWITCH_CID + "", + } + TWITCH_FOLLOWERS_SOURCE = ( + "https://api.twitch.tv/helix/channels" + "/followers?" + "broadcaster_id=" + TWITCH_UID + ) + print(" | Attempting to GET Twitch JSON!") + try: + with requests.get( + url=TWITCH_FOLLOWERS_SOURCE, headers=twitch_header + ) as twitch_response: + twitch_json = twitch_response.json() + except ConnectionError as e: + print(f"Connection Error: {e}") + print("Retrying in 10 seconds") + + if DEBUG: + print(f" | Full API GET URL: {TWITCH_FOLLOWERS_SOURCE}") + print(f" | Header: {twitch_header}") + print(f" | JSON Full Response: {twitch_json}") + + if "status" in twitch_json: + twitch_error_status = twitch_json["status"] + print(f"❌ Status: {twitch_error_status}") + + if "error" in twitch_json: + twitch_error = twitch_json["error"] + print(f"❌ Error: {twitch_error}") + + if "message" in twitch_json: + twitch_error_msg = twitch_json["message"] + print(f"❌ Message: {twitch_error_msg}") + + if "total" in twitch_json: + print(" | ✅ Twitch JSON!") + twitch_followers = twitch_json["total"] + print(f" | | Followers: {twitch_followers}") + + print("\nFinished!") + print(f"Board Uptime: {time_calc(time.monotonic())}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + + except (ValueError, RuntimeError) as e: + print(f"Failed to get data, retrying\n {e}") + time.sleep(60) + break + time.sleep(SLEEP_TIME) diff --git a/examples/wifi/expanded/requests_wifi_api_twitter.py b/examples/wifi/expanded/requests_wifi_api_twitter.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/wifi/expanded/requests_wifi_api_youtube.py b/examples/wifi/expanded/requests_wifi_api_youtube.py new file mode 100644 index 0000000..a616d99 --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_api_youtube.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 8.2.x +"""YouTube API Subscriber Count Example""" + +import os +import time + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + +# API Polling Rate +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +SLEEP_TIME = 900 + +# Set debug to True for full JSON response. +# WARNING: Will show credentials +DEBUG = False + +# Ensure these are uncommented and in settings.toml +# YOUTUBE_USERNAME = "Your YouTube Username", +# YOUTUBE_TOKEN = "Your long API developer token", + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") +# Requires YouTube/Google API key +# https://console.cloud.google.com/apis/dashboard +YT_USERNAME = os.getenv("YOUTUBE_USERNAME") +YT_TOKEN = os.getenv("YOUTUBE_TOKEN") + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +# https://youtube.googleapis.com/youtube/v3/channels?part=statistics&forUsername=[YOUR_USERNAME]&key=[YOUR_API_KEY] +YOUTUBE_SOURCE = ( + "https://youtube.googleapis.com/youtube/v3/channels?part=statistics&forUsername=" + + str(YT_USERNAME) + + "&key=" + + str(YT_TOKEN) +) + +while True: + # Connect to Wi-Fi + print("\nConnecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ Wifi!") + try: + print(" | Attempting to GET YouTube JSON...") + try: + with requests.get(url=YOUTUBE_SOURCE) as youtube_response: + youtube_json = youtube_response.json() + except ConnectionError as e: + print("Connection Error:", e) + print("Retrying in 10 seconds") + print(" | ✅ YouTube JSON!") + + if DEBUG: + print(f" | Full API GET URL: {YOUTUBE_SOURCE}") + print(f" | Full API Dump: {youtube_json}") + + # Key:Value RESPONSES + if "pageInfo" in youtube_json: + totalResults = youtube_json["pageInfo"]["totalResults"] + print(f" | | Matching Results: {totalResults}") + + if "items" in youtube_json: + YT_request_kind = youtube_json["items"][0]["kind"] + print(f" | | Request Kind: {YT_request_kind}") + + YT_channel_id = youtube_json["items"][0]["id"] + print(f" | | Channel ID: {YT_channel_id}") + + YT_videoCount = youtube_json["items"][0]["statistics"]["videoCount"] + print(f" | | Videos: {YT_videoCount}") + + YT_viewCount = youtube_json["items"][0]["statistics"]["viewCount"] + print(f" | | Views: {YT_viewCount}") + + YT_subsCount = youtube_json["items"][0]["statistics"]["subscriberCount"] + print(f" | | Subscribers: {YT_subsCount}") + + if "kind" in youtube_json: + YT_response_kind = youtube_json["kind"] + print(f" | | Response Kind: {YT_response_kind}") + + print("\nFinished!") + print(f"Board Uptime: {time_calc(time.monotonic())}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + + except (ValueError, RuntimeError) as e: + print(f"Failed to get data, retrying\n {e}") + time.sleep(60) + break + time.sleep(SLEEP_TIME) diff --git a/examples/wifi/expanded/requests_wifi_file_upload.py b/examples/wifi/expanded/requests_wifi_file_upload.py new file mode 100644 index 0000000..bd9ac2a --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_file_upload.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries +# SPDX-License-Identifier: MIT + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +URL = "https://httpbin.org/post" + +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + +with open("requests_wifi_file_upload_image.png", "rb") as file_handle: + files = { + "file": ( + "requests_wifi_file_upload_image.png", + file_handle, + "image/png", + {"CustomHeader": "BlinkaRocks"}, + ), + "othervalue": (None, "HelloWorld"), + } + + with requests.post(URL, files=files) as response: + print(response.content) diff --git a/examples/wifi/expanded/requests_wifi_file_upload_image.png b/examples/wifi/expanded/requests_wifi_file_upload_image.png new file mode 100644 index 0000000..da2d219 Binary files /dev/null and b/examples/wifi/expanded/requests_wifi_file_upload_image.png differ diff --git a/examples/wifi/expanded/requests_wifi_file_upload_image.png.license b/examples/wifi/expanded/requests_wifi_file_upload_image.png.license new file mode 100644 index 0000000..6e0776a --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_file_upload_image.png.license @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2024 Tim Cocks +# SPDX-License-Identifier: CC-BY-4.0 diff --git a/examples/wifi/expanded/requests_wifi_multiple_cookies.py b/examples/wifi/expanded/requests_wifi_multiple_cookies.py new file mode 100644 index 0000000..21fe9ba --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_multiple_cookies.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2022 Alec Delaney +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 9.0 +"""Multiple Cookies Example written for MagTag""" + +import os + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +COOKIE_TEST_URL = "https://www.adafruit.com" + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + +print(f"\nConnecting to {ssid}...") +try: + # Connect to the Wi-Fi network + wifi.radio.connect(ssid, password) +except OSError as e: + print(f"❌ OSError: {e}") +print("✅ Wifi!") + +# URL GET Request +with requests.get(COOKIE_TEST_URL) as response: + print(f" | Fetching Cookies: {COOKIE_TEST_URL}") + + # Spilt up the cookies by ", " + elements = response.headers["set-cookie"].split(", ") + + # NOTE: Some cookies use ", " when describing dates. This code will iterate through + # the previously split up 'set-cookie' header value and piece back together cookies + # that were accidentally split up for this reason + days_of_week = ("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat") + elements_iter = iter(elements) + cookie_list = [] + for element in elements_iter: + captured_day = [day for day in days_of_week if element.endswith(day)] + if captured_day: + cookie_list.append(element + ", " + next(elements_iter)) + else: + cookie_list.append(element) + + # Pring the information about the cookies + print(f" | Total Cookies: {len(cookie_list)}") + print("-" * 80) + + for cookie in cookie_list: + print(f" | 🍪 {cookie}") + print("-" * 80) + +print("Finished!") diff --git a/examples/wifi/expanded/requests_wifi_rachio_irrigation.py b/examples/wifi/expanded/requests_wifi_rachio_irrigation.py new file mode 100644 index 0000000..55a46a1 --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_rachio_irrigation.py @@ -0,0 +1,246 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Coded for Circuit Python 9.x +"""Rachio Irrigation Timer API Example""" + +import os +import time + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Rachio API Key required (comes with purchase of a device) +# API is rate limited to 1700 calls per day. +# https://support.rachio.com/en_us/public-api-documentation-S1UydL1Fv +# https://rachio.readme.io/reference/getting-started +RACHIO_KEY = os.getenv("RACHIO_APIKEY") +RACHIO_PERSONID = os.getenv("RACHIO_PERSONID") + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +# API Polling Rate +# 900 = 15 mins, 1800 = 30 mins, 3600 = 1 hour +SLEEP_TIME = 900 + +# Set debug to True for full JSON response. +# WARNING: absolutely shows extremely sensitive personal information & credentials +# Including your real name, latitude, longitude, account id, mac address, etc... +DEBUG = False + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) + +RACHIO_HEADER = {"Authorization": " Bearer " + RACHIO_KEY} +RACHIO_SOURCE = "https://api.rach.io/1/public/person/info/" +RACHIO_PERSON_SOURCE = "https://api.rach.io/1/public/person/" + + +def obfuscating_asterix(obfuscate_object, direction, characters=2): + """ + Obfuscates a string with asterisks except for a specified number of characters. + param object: str The string to obfuscate with asterisks + param direction: str Option either 'prepend', 'append', or 'all' direction + param characters: int The number of characters to keep unobfuscated (default is 2) + """ + object_len = len(obfuscate_object) + if direction not in {"prepend", "append", "all"}: + raise ValueError("Invalid direction. Use 'prepend', 'append', or 'all'.") + if characters >= object_len and direction != "all": + # If characters greater than or equal to string length, + # return the original string as it can't be obfuscated. + return obfuscate_object + asterix_replace = "*" * object_len + if direction == "append": + asterix_final = obfuscate_object[:characters] + "*" * (object_len - characters) + elif direction == "prepend": + asterix_final = "*" * (object_len - characters) + obfuscate_object[-characters:] + elif direction == "all": + # Replace all characters with asterisks + asterix_final = asterix_replace + + return asterix_final + + +def time_calc(input_time): + """Converts seconds to minutes/hours/days""" + if input_time < 60: + return f"{input_time:.0f} seconds" + if input_time < 3600: + return f"{input_time / 60:.0f} minutes" + if input_time < 86400: + return f"{input_time / 60 / 60:.0f} hours" + return f"{input_time / 60 / 60 / 24:.1f} days" + + +def _format_datetime(datetime): + """F-String formatted struct time conversion""" + return ( + f"{datetime.tm_mon:02}/" + + f"{datetime.tm_mday:02}/" + + f"{datetime.tm_year:02} " + + f"{datetime.tm_hour:02}:" + + f"{datetime.tm_min:02}:" + + f"{datetime.tm_sec:02}" + ) + + +while True: + # Connect to Wi-Fi + print("\nConnecting to WiFi...") + while not wifi.radio.ipv4_address: + try: + wifi.radio.connect(ssid, password) + except ConnectionError as e: + print("❌ Connection Error:", e) + print("Retrying in 10 seconds") + print("✅ Wifi!") + + # RETREIVE PERSONID AND PASTE IT TO SETTINGS.TOML + if RACHIO_PERSONID is None or RACHIO_PERSONID == "": + try: + print(" | Attempting to GET Rachio Authorization") + try: + with requests.get(url=RACHIO_SOURCE, headers=RACHIO_HEADER) as rachio_response: + rachio_json = rachio_response.json() + except ConnectionError as e: + print("Connection Error:", e) + print("Retrying in 10 seconds") + print(" | ✅ Authorized") + + rachio_id = rachio_json["id"] + print("\nADD THIS 🔑 TO YOUR SETTINGS.TOML FILE!") + print(f'RACHIO_PERSONID = "{rachio_id}"') + + if DEBUG: + print("\nFull API GET URL: ", RACHIO_SOURCE) + print(rachio_json) + + except (ValueError, RuntimeError) as e: + print(f"Failed to GET data: {e}") + time.sleep(60) + break + print("\nThis script can only continue when a proper APIKey & PersonID is used.") + print("\nFinished!") + print("===============================") + time.sleep(SLEEP_TIME) + + # Main Script + if RACHIO_PERSONID is not None and RACHIO_PERSONID != "": + try: + print(" | Attempting to GET Rachio JSON") + try: + with requests.get( + url=RACHIO_PERSON_SOURCE + RACHIO_PERSONID, headers=RACHIO_HEADER + ) as rachio_response: + rachio_json = rachio_response.json() + except ConnectionError as e: + print("Connection Error:", e) + print("Retrying in 10 seconds") + print(" | ✅ Rachio JSON") + response_headers = rachio_response.headers + if DEBUG: + print(f"Response Headers: {response_headers}") + call_limit = int(response_headers["x-ratelimit-limit"]) + calls_remaining = int(response_headers["x-ratelimit-remaining"]) + calls_made_today = call_limit - calls_remaining + + print(" | | Headers:") + print(f" | | | Date: {response_headers['date']}") + print(f" | | | Maximum Daily Requests: {call_limit}") + print(f" | | | Today's Requests: {calls_made_today}") + print(f" | | | Remaining Requests: {calls_remaining}") + print(f" | | | Limit Reset: {response_headers['x-ratelimit-reset']}") + print(f" | | | Content Type: {response_headers['content-type']}") + + rachio_id = rachio_json["id"] + rachio_id_ast = obfuscating_asterix(rachio_id, "append", 3) + print(" | | PersonID: ", rachio_id_ast) + + rachio_username = rachio_json["username"] + rachio_username_ast = obfuscating_asterix(rachio_username, "append", 3) + print(" | | Username: ", rachio_username_ast) + + rachio_name = rachio_json["fullName"] + rachio_name_ast = obfuscating_asterix(rachio_name, "append", 3) + print(" | | Full Name: ", rachio_name_ast) + + rachio_deleted = rachio_json["deleted"] + if not rachio_deleted: + print(" | | Account Status: Active") + else: + print(" | | Account Status?: Deleted!") + + rachio_createdate = rachio_json["createDate"] + rachio_timezone_offset = rachio_json["devices"][0]["utcOffset"] + # Rachio Unix time is in milliseconds, convert to seconds + rachio_createdate_seconds = rachio_createdate // 1000 + rachio_timezone_offset_seconds = rachio_timezone_offset // 1000 + # Apply timezone offset in seconds + local_unix_time = rachio_createdate_seconds + rachio_timezone_offset_seconds + if DEBUG: + print(f" | | Unix Registration Date: {rachio_createdate}") + print(f" | | Unix Timezone Offset: {rachio_timezone_offset}") + current_struct_time = time.localtime(local_unix_time) + final_timestamp = f"{_format_datetime(current_struct_time)}" + print(f" | | Registration Date: {final_timestamp}") + + rachio_devices = rachio_json["devices"][0]["name"] + print(" | | Device: ", rachio_devices) + + rachio_model = rachio_json["devices"][0]["model"] + print(" | | | Model: ", rachio_model) + + rachio_serial = rachio_json["devices"][0]["serialNumber"] + rachio_serial_ast = obfuscating_asterix(rachio_serial, "append") + print(" | | | Serial Number: ", rachio_serial_ast) + + rachio_mac = rachio_json["devices"][0]["macAddress"] + rachio_mac_ast = obfuscating_asterix(rachio_mac, "append") + print(" | | | MAC Address: ", rachio_mac_ast) + + rachio_status = rachio_json["devices"][0]["status"] + print(" | | | Device Status: ", rachio_status) + + rachio_timezone = rachio_json["devices"][0]["timeZone"] + print(" | | | Time Zone: ", rachio_timezone) + + # Latitude & Longtitude are used for smart watering & rain delays + rachio_latitude = str(rachio_json["devices"][0]["latitude"]) + rachio_lat_ast = obfuscating_asterix(rachio_latitude, "all") + print(" | | | Latitude: ", rachio_lat_ast) + + rachio_longitude = str(rachio_json["devices"][0]["longitude"]) + rachio_long_ast = obfuscating_asterix(rachio_longitude, "all") + print(" | | | Longitude: ", rachio_long_ast) + + rachio_rainsensor = rachio_json["devices"][0]["rainSensorTripped"] + print(" | | | Rain Sensor: ", rachio_rainsensor) + + rachio_zone0 = rachio_json["devices"][0]["zones"][0]["name"] + rachio_zone1 = rachio_json["devices"][0]["zones"][1]["name"] + rachio_zone2 = rachio_json["devices"][0]["zones"][2]["name"] + rachio_zone3 = rachio_json["devices"][0]["zones"][3]["name"] + zones = f"{rachio_zone0}, {rachio_zone1}, {rachio_zone2}, {rachio_zone3}" + print(f" | | | Zones: {zones}") + + if DEBUG: + print(f"\nFull API GET URL: {RACHIO_PERSON_SOURCE+rachio_id}") + print(rachio_json) + + print("\nFinished!") + print(f"Board Uptime: {time_calc(time.monotonic())}") + print(f"Next Update: {time_calc(SLEEP_TIME)}") + print("===============================") + + except (ValueError, RuntimeError) as e: + print(f"Failed to get data, retrying\n {e}") + time.sleep(60) + break + + time.sleep(SLEEP_TIME) diff --git a/examples/wifi/expanded/requests_wifi_status_codes.py b/examples/wifi/expanded/requests_wifi_status_codes.py new file mode 100644 index 0000000..a933c28 --- /dev/null +++ b/examples/wifi/expanded/requests_wifi_status_codes.py @@ -0,0 +1,138 @@ +# SPDX-FileCopyrightText: 2024 DJDevon3 +# SPDX-License-Identifier: MIT +# Updated for Circuit Python 9.0 +# https://help.openai.com/en/articles/6825453-chatgpt-release-notes +# https://chat.openai.com/share/32ef0c5f-ac92-4d36-9d1e-0f91e0c4c574 +"""WiFi Status Codes Example""" + +import os +import time + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) +rssi = wifi.radio.ap_info.rssi + + +def print_http_status(expected_code, actual_code, description): + """Returns HTTP status code and description""" + if "100" <= actual_code <= "103": + print(f" | ✅ Status Test Expected: {expected_code} Actual: {actual_code} - {description}") + elif "200" == actual_code: + print(f" | 🆗 Status Test Expected: {expected_code} Actual: {actual_code} - {description}") + elif "201" <= actual_code <= "299": + print(f" | ✅ Status Test Expected: {expected_code} Actual: {actual_code} - {description}") + elif "300" <= actual_code <= "600": + print(f" | ❌ Status Test Expected: {expected_code} Actual: {actual_code} - {description}") + else: + print( + f" | Unknown Response Status Expected: {expected_code} " + + f"Actual: {actual_code} - {description}" + ) + + +# All HTTP Status Codes +http_status_codes = { + "100": "Continue", + "101": "Switching Protocols", + "102": "Processing", + "103": "Early Hints", + "200": "OK", + "201": "Created", + "202": "Accepted", + "203": "Non-Authoritative Information", + "204": "No Content", + "205": "Reset Content", + "206": "Partial Content", + "207": "Multi-Status", + "208": "Already Reported", + "226": "IM Used", + "300": "Multiple Choices", + "301": "Moved Permanently", + "302": "Found", + "303": "See Other", + "304": "Not Modified", + "305": "Use Proxy", + "306": "Unused", + "307": "Temporary Redirect", + "308": "Permanent Redirect", + "400": "Bad Request", + "401": "Unauthorized", + "402": "Payment Required", + "403": "Forbidden", + "404": "Not Found", + "405": "Method Not Allowed", + "406": "Not Acceptable", + "407": "Proxy Authentication Required", + "408": "Request Timeout", + "409": "Conflict", + "410": "Gone", + "411": "Length Required", + "412": "Precondition Failed", + "413": "Payload Too Large", + "414": "URI Too Long", + "415": "Unsupported Media Type", + "416": "Range Not Satisfiable", + "417": "Expectation Failed", + "418": "I'm a teapot", + "421": "Misdirected Request", + "422": "Unprocessable Entity", + "423": "Locked", + "424": "Failed Dependency", + "425": "Too Early", + "426": "Upgrade Required", + "428": "Precondition Required", + "429": "Too Many Requests", + "431": "Request Header Fields Too Large", + "451": "Unavailable For Legal Reasons", + "500": "Internal Server Error", + "501": "Not Implemented", + "502": "Bad Gateway", + "503": "Service Unavailable", + "504": "Gateway Timeout", + "505": "HTTP Version Not Supported", + "506": "Variant Also Negotiates", + "507": "Insufficient Storage", + "508": "Loop Detected", + "510": "Not Extended", + "511": "Network Authentication Required", +} + +STATUS_TEST_URL = "https://httpbin.org/status/" + +print(f"\nConnecting to {ssid}...") +print(f"Signal Strength: {rssi}") +try: + # Connect to the Wi-Fi network + wifi.radio.connect(ssid, password) +except OSError as e: + print(f"❌ OSError: {e}") +print("✅ Wifi!") + + +print(f" | Status Code Test: {STATUS_TEST_URL}") +# Some return errors then confirm the error (that's a good thing) +# Demonstrates not all errors have the same behavior +# Some 300 level responses contain redirects that requests automatically follows +# By default the response object will contain the status code from the final +# response after all redirect, so it can differ from the expected status code. +for current_code in sorted(http_status_codes.keys(), key=int): + header_status_test_url = STATUS_TEST_URL + current_code + with requests.get(header_status_test_url) as response: + response_status_code = str(response.status_code) + SORT_STATUS_DESC = http_status_codes.get(response_status_code, "Unknown Status Code") + print_http_status(current_code, response_status_code, SORT_STATUS_DESC) + + # Rate limit ourselves a little to avoid strain on server + time.sleep(0.5) +print("Finished!") diff --git a/examples/wifi/requests_wifi_advanced.py b/examples/wifi/requests_wifi_advanced.py new file mode 100644 index 0000000..bc29df7 --- /dev/null +++ b/examples/wifi/requests_wifi_advanced.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-License-Identifier: MIT +# Updated for Circuit Python 9.0 + +"""WiFi Advanced Example""" + +import os + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) +rssi = wifi.radio.ap_info.rssi + +# URL for GET request +JSON_GET_URL = "https://httpbin.org/get" +# Define a custom header as a dict. +headers = {"user-agent": "blinka/1.0.0"} + +print(f"\nConnecting to {ssid}...") +print(f"Signal Strength: {rssi}") +try: + # Connect to the Wi-Fi network + wifi.radio.connect(ssid, password) +except OSError as e: + print(f"❌ OSError: {e}") +print("✅ Wifi!") + +# Define a custom header as a dict. +headers = {"user-agent": "blinka/1.0.0"} +print(f" | Fetching URL {JSON_GET_URL}") + +# Use with statement for retreiving GET request data +with requests.get(JSON_GET_URL, headers=headers) as response: + json_data = response.json() + headers = json_data["headers"] + content_type = response.headers.get("content-type", "") + date = response.headers.get("date", "") + if response.status_code == 200: + print(f" | 🆗 Status Code: {response.status_code}") + else: + print(f" | ❌ Status Code: {response.status_code}") + print(f" | | Custom User-Agent Header: {headers['User-Agent']}") + print(f" | | Content-Type: {content_type}") + print(f" | | Response Timestamp: {date}") diff --git a/examples/wifi/requests_wifi_simpletest.py b/examples/wifi/requests_wifi_simpletest.py new file mode 100644 index 0000000..ddc70a2 --- /dev/null +++ b/examples/wifi/requests_wifi_simpletest.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-License-Identifier: MIT +# Updated for Circuit Python 9.0 +"""WiFi Simpletest""" + +import os + +import adafruit_connection_manager +import wifi + +import adafruit_requests + +# Get WiFi details, ensure these are setup in settings.toml +ssid = os.getenv("CIRCUITPY_WIFI_SSID") +password = os.getenv("CIRCUITPY_WIFI_PASSWORD") + +TEXT_URL = "http://wifitest.adafruit.com/testwifi/index.html" +JSON_GET_URL = "https://httpbin.org/get" +JSON_POST_URL = "https://httpbin.org/post" + +# Initalize Wifi, Socket Pool, Request Session +pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio) +requests = adafruit_requests.Session(pool, ssl_context) +rssi = wifi.radio.ap_info.rssi + +print(f"\nConnecting to {ssid}...") +print(f"Signal Strength: {rssi}") +try: + # Connect to the Wi-Fi network + wifi.radio.connect(ssid, password) +except OSError as e: + print(f"❌ OSError: {e}") +print("✅ Wifi!") + +print(f" | GET Text Test: {TEXT_URL}") +with requests.get(TEXT_URL) as response: + print(f" | ✅ GET Response: {response.text}") +print("-" * 80) + +print(f" | GET Full Response Test: {JSON_GET_URL}") +with requests.get(JSON_GET_URL) as response: + print(f" | ✅ Unparsed Full JSON Response: {response.json()}") +print("-" * 80) + +DATA = "This is an example of a JSON value" +print(f" | ✅ JSON 'value' POST Test: {JSON_POST_URL} {DATA}") +with requests.post(JSON_POST_URL, data=DATA) as response: + json_resp = response.json() + # Parse out the 'data' key from json_resp dict. + print(f" | ✅ JSON 'value' Response: {json_resp['data']}") +print("-" * 80) + +json_data = {"Date": "January 1, 1970"} +print(f" | ✅ JSON 'key':'value' POST Test: {JSON_POST_URL} {json_data}") +with requests.post(JSON_POST_URL, json=json_data) as response: + json_resp = response.json() + # Parse out the 'json' key from json_resp dict. + print(f" | ✅ JSON 'key':'value' Response: {json_resp['json']}") +print("-" * 80) + +print("Finished!") diff --git a/examples/wiznet5k/requests_wiznet5k_advanced.py b/examples/wiznet5k/requests_wiznet5k_advanced.py new file mode 100644 index 0000000..1f068ee --- /dev/null +++ b/examples/wiznet5k/requests_wiznet5k_advanced.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-License-Identifier: MIT + +import adafruit_connection_manager +import board +import busio +from adafruit_wiznet5k.adafruit_wiznet5k import WIZNET5K +from digitalio import DigitalInOut + +import adafruit_requests + +cs = DigitalInOut(board.D10) +spi_bus = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO) + +# Initialize ethernet interface with DHCP +radio = WIZNET5K(spi_bus, cs) + +# Initialize a requests session +pool = adafruit_connection_manager.get_radio_socketpool(radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio) +requests = adafruit_requests.Session(pool, ssl_context) + +JSON_GET_URL = "http://httpbin.org/get" + +# Define a custom header as a dict. +headers = {"user-agent": "blinka/1.0.0"} + +print("Fetching JSON data from %s..." % JSON_GET_URL) +with requests.get(JSON_GET_URL, headers=headers) as response: + print("-" * 60) + + json_data = response.json() + headers = json_data["headers"] + print("Response's Custom User-Agent Header: {0}".format(headers["User-Agent"])) + print("-" * 60) + + # Read Response's HTTP status code + print("Response HTTP Status Code: ", response.status_code) + print("-" * 60) diff --git a/examples/wiznet5k/requests_wiznet5k_simpletest.py b/examples/wiznet5k/requests_wiznet5k_simpletest.py new file mode 100644 index 0000000..14c49ec --- /dev/null +++ b/examples/wiznet5k/requests_wiznet5k_simpletest.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-License-Identifier: MIT + +import adafruit_connection_manager +import board +import busio +from adafruit_wiznet5k.adafruit_wiznet5k import WIZNET5K +from digitalio import DigitalInOut + +import adafruit_requests + +cs = DigitalInOut(board.D10) +spi_bus = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO) + +# Initialize ethernet interface with DHCP +radio = WIZNET5K(spi_bus, cs) + +# Initialize a requests session +pool = adafruit_connection_manager.get_radio_socketpool(radio) +ssl_context = adafruit_connection_manager.get_radio_ssl_context(radio) +requests = adafruit_requests.Session(pool, ssl_context) + +TEXT_URL = "http://wifitest.adafruit.com/testwifi/index.html" +JSON_GET_URL = "http://httpbin.org/get" +JSON_POST_URL = "http://httpbin.org/post" + +print("Fetching text from %s" % TEXT_URL) +with requests.get(TEXT_URL) as response: + print("-" * 40) + print("Text Response: ", response.text) + print("-" * 40) + +print("Fetching JSON data from %s" % JSON_GET_URL) +with requests.get(JSON_GET_URL) as response: + print("-" * 40) + print("JSON Response: ", response.json()) + print("-" * 40) + +data = "31F" +print(f"POSTing data to {JSON_POST_URL}: {data}") +with requests.post(JSON_POST_URL, data=data) as response: + print("-" * 40) + json_resp = response.json() + # Parse out the 'data' key from json_resp dict. + print("Data received from server:", json_resp["data"]) + print("-" * 40) + +json_data = {"Date": "July 25, 2019"} +print(f"POSTing data to {JSON_POST_URL}: {json_data}") +with requests.post(JSON_POST_URL, json=json_data) as response: + print("-" * 40) + json_resp = response.json() + # Parse out the 'json' key from json_resp dict. + print("JSON Data received from server:", json_resp["json"]) + print("-" * 40) diff --git a/optional_requirements.txt b/optional_requirements.txt new file mode 100644 index 0000000..38e5c0c --- /dev/null +++ b/optional_requirements.txt @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2022 Alec Delaney, for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +requests diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..291a9cc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: 2022 Alec Delaney for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +[build-system] +requires = [ + "setuptools", + "wheel", + "setuptools-scm", +] + +[project] +name = "adafruit-circuitpython-requests" +description = "A requests-like library for web interfacing" +version = "0.0.0+auto.0" +readme = "README.rst" +authors = [ + {name = "Adafruit Industries", email = "circuitpython@adafruit.com"} +] +urls = {Homepage = "https://github.com/adafruit/Adafruit_CircuitPython_Requests"} +keywords = [ + "adafruit", + "blinka", + "circuitpython", + "micropython", + "requests", + "requests,", + "networking", +] +license = {text = "MIT"} +classifiers = [ + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Embedded Systems", + "Topic :: System :: Hardware", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", +] +dynamic = ["dependencies", "optional-dependencies"] + +[tool.ruff] +target-version = "py38" + +[tool.ruff.lint] +select = ["I", "PL", "UP"] +ignore = ["PLR2004", "UP030"] + +[tool.ruff.format] +line-ending = "lf" + +[tool.setuptools] +py-modules = ["adafruit_requests"] + +[tool.setuptools.dynamic] +dependencies = {file = ["requirements.txt"]} +optional-dependencies = {optional = {file = ["optional_requirements.txt"]}} diff --git a/requirements.txt b/requirements.txt index edf9394..2505288 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,6 @@ +# SPDX-FileCopyrightText: 2022 Alec Delaney, for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + Adafruit-Blinka +Adafruit-Circuitpython-ConnectionManager diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..a3b4844 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +target-version = "py38" +line-length = 100 + +[lint] +select = ["I", "PL", "UP"] + +extend-select = [ + "D419", # empty-docstring + "E501", # line-too-long + "W291", # trailing-whitespace + "PLC0414", # useless-import-alias + "PLC2401", # non-ascii-name + "PLC2801", # unnecessary-dunder-call + "PLC3002", # unnecessary-direct-lambda-call + "PLE0101", # return-in-init + "F706", # return-outside-function + "F704", # yield-outside-function + "PLE0116", # continue-in-finally + "PLE0117", # nonlocal-without-binding + "PLE0241", # duplicate-bases + "PLE0302", # unexpected-special-method-signature + "PLE0604", # invalid-all-object + "PLE0605", # invalid-all-format + "PLE0643", # potential-index-error + "PLE0704", # misplaced-bare-raise + "PLE1141", # dict-iter-missing-items + "PLE1142", # await-outside-async + "PLE1205", # logging-too-many-args + "PLE1206", # logging-too-few-args + "PLE1307", # bad-string-format-type + "PLE1310", # bad-str-strip-call + "PLE1507", # invalid-envvar-value + "PLE2502", # bidirectional-unicode + "PLE2510", # invalid-character-backspace + "PLE2512", # invalid-character-sub + "PLE2513", # invalid-character-esc + "PLE2514", # invalid-character-nul + "PLE2515", # invalid-character-zero-width-space + "PLR0124", # comparison-with-itself + "PLR0202", # no-classmethod-decorator + "PLR0203", # no-staticmethod-decorator + "UP004", # useless-object-inheritance + "PLR0206", # property-with-parameters + "PLR0904", # too-many-public-methods + "PLR0911", # too-many-return-statements + "PLR0912", # too-many-branches + "PLR0913", # too-many-arguments + "PLR0914", # too-many-locals + "PLR0915", # too-many-statements + "PLR0916", # too-many-boolean-expressions + "PLR1702", # too-many-nested-blocks + "PLR1704", # redefined-argument-from-local + "PLR1711", # useless-return + "C416", # unnecessary-comprehension + "PLR1733", # unnecessary-dict-index-lookup + "PLR1736", # unnecessary-list-index-lookup + + # ruff reports this rule is unstable + #"PLR6301", # no-self-use + + "PLW0108", # unnecessary-lambda + "PLW0120", # useless-else-on-loop + "PLW0127", # self-assigning-variable + "PLW0129", # assert-on-string-literal + "B033", # duplicate-value + "PLW0131", # named-expr-without-context + "PLW0245", # super-without-brackets + "PLW0406", # import-self + "PLW0602", # global-variable-not-assigned + "PLW0603", # global-statement + "PLW0604", # global-at-module-level + + # fails on the try: import typing used by libraries + #"F401", # unused-import + + "F841", # unused-variable + "E722", # bare-except + "PLW0711", # binary-op-exception + "PLW1501", # bad-open-mode + "PLW1508", # invalid-envvar-default + "PLW1509", # subprocess-popen-preexec-fn + "PLW2101", # useless-with-lock + "PLW3301", # nested-min-max +] + +ignore = [ + "PLR2004", # magic-value-comparison + "UP030", # format literals + "PLW1514", # unspecified-encoding + +] + +[format] +line-ending = "lf" diff --git a/setup.py b/setup.py deleted file mode 100755 index cff2828..0000000 --- a/setup.py +++ /dev/null @@ -1,53 +0,0 @@ -"""A setuptools based setup module. - -See: -https://packaging.python.org/en/latest/distributing.html -https://github.com/pypa/sampleproject -""" - -from setuptools import setup, find_packages - -# To use a consistent encoding -from codecs import open -from os import path - -here = path.abspath(path.dirname(__file__)) - -# Get the long description from the README file -with open(path.join(here, "README.rst"), encoding="utf-8") as f: - long_description = f.read() - -setup( - name="adafruit-circuitpython-requests", - use_scm_version=True, - setup_requires=["setuptools_scm"], - description="A requests-like library for web interfacing", - long_description=long_description, - long_description_content_type="text/x-rst", - # The project's main homepage. - url="https://github.com/adafruit/Adafruit_CircuitPython_Requests", - # Author details - author="Adafruit Industries", - author_email="circuitpython@adafruit.com", - install_requires=["Adafruit-Blinka"], - # Choose your license - license="MIT", - # See https://pypi.python.org/pypi?%3Aaction=list_classifiers - classifiers=[ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries", - "Topic :: System :: Hardware", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - ], - # What does your project relate to? - keywords="adafruit blinka circuitpython micropython requests requests, networking", - # You can just specify the packages manually here if your project is - # simple. Or you can use find_packages(). - # TODO: IF LIBRARY FILES ARE A PACKAGE FOLDER, - # CHANGE `py_modules=['...']` TO `packages=['...']` - py_modules=["adafruit_requests"], -) diff --git a/tests/chunk_test.py b/tests/chunk_test.py index 0982652..4be0e05 100644 --- a/tests/chunk_test.py +++ b/tests/chunk_test.py @@ -1,12 +1,21 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +"""Chunk Tests""" + from unittest import mock + import mocket + import adafruit_requests -ip = "1.2.3.4" -host = "wifitest.adafruit.com" -path = "/testwifi/index.html" -text = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" -headers = b"HTTP/1.0 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" +IP = "1.2.3.4" +HOST = "wifitest.adafruit.com" +PATH = "/testwifi/index.html" +TEXT = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" +HEADERS = b"HTTP/1.0 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" +HEADERS_EXTRA_SPACE = b"HTTP/1.0 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" def _chunk(response, split, extra=b""): @@ -19,11 +28,7 @@ def _chunk(response, split, extra=b""): chunk_size = remaining new_i = i + chunk_size chunked += ( - hex(chunk_size)[2:].encode("ascii") - + extra - + b"\r\n" - + response[i:new_i] - + b"\r\n" + hex(chunk_size)[2:].encode("ascii") + extra + b"\r\n" + response[i:new_i] + b"\r\n" ) i = new_i # The final chunk is zero length. @@ -31,18 +36,20 @@ def _chunk(response, split, extra=b""): return chunked -def do_test_get_text(extra=b""): +def do_test_get_text( + extra=b"", +): pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - c = _chunk(text, 33, extra) - print(c) - sock = mocket.Mocket(headers + c) + pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + chunk = _chunk(TEXT, 33, extra) + print(chunk) + sock = mocket.Mocket(HEADERS + chunk) pool.socket.return_value = sock - s = adafruit_requests.Session(pool) - r = s.get("http://" + host + path) + requests_session = adafruit_requests.Session(pool) + response = requests_session.get("http://" + HOST + PATH) - sock.connect.assert_called_once_with((ip, 80)) + sock.connect.assert_called_once_with((IP, 80)) sock.send.assert_has_calls( [ @@ -53,9 +60,13 @@ def do_test_get_text(extra=b""): ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + ] ) - assert r.text == str(text, "utf-8") + assert response.text == str(TEXT, "utf-8") def test_get_text(): @@ -66,19 +77,22 @@ def test_get_text_extra(): do_test_get_text(b";blahblah; blah") -def do_test_close_flush(extra=b""): - """Test that a chunked response can be closed even when the request contents were not accessed.""" +def do_test_close_flush( + extra=b"", +): + """Test that a chunked response can be closed even when the + request contents were not accessed.""" pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - c = _chunk(text, 33, extra) - print(c) - sock = mocket.Mocket(headers + c) + pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + chunk = _chunk(TEXT, 33, extra) + print(chunk) + sock = mocket.Mocket(HEADERS + chunk) pool.socket.return_value = sock - s = adafruit_requests.Session(pool) - r = s.get("http://" + host + path) + requests_session = adafruit_requests.Session(pool) + response = requests_session.get("http://" + HOST + PATH) - sock.connect.assert_called_once_with((ip, 80)) + sock.connect.assert_called_once_with((IP, 80)) sock.send.assert_has_calls( [ @@ -89,10 +103,14 @@ def do_test_close_flush(extra=b""): ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + ] ) - r.close() + response.close() def test_close_flush(): @@ -101,3 +119,36 @@ def test_close_flush(): def test_close_flush_extra(): do_test_close_flush(b";blahblah; blah") + + +def do_test_get_text_extra_space( + extra=b"", +): + pool = mocket.MocketPool() + pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 80)),) + chunk = _chunk(TEXT, 33, extra) + print(chunk) + sock = mocket.Mocket(HEADERS_EXTRA_SPACE + chunk) + pool.socket.return_value = sock + + requests_session = adafruit_requests.Session(pool) + response = requests_session.get("http://" + HOST + PATH) + + sock.connect.assert_called_once_with((IP, 80)) + + sock.send.assert_has_calls( + [ + mock.call(b"GET"), + mock.call(b" /"), + mock.call(b"testwifi/index.html"), + mock.call(b" HTTP/1.1\r\n"), + ] + ) + sock.send.assert_has_calls( + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + ] + ) + assert response.text == str(TEXT, "utf-8") diff --git a/tests/chunked_redirect_test.py b/tests/chunked_redirect_test.py new file mode 100644 index 0000000..871dc21 --- /dev/null +++ b/tests/chunked_redirect_test.py @@ -0,0 +1,241 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +"""Redirection Tests""" + +from unittest import mock + +import mocket +from chunk_test import _chunk + +import adafruit_requests + +IP = "1.2.3.4" +HOST = "docs.google.com" +PATH_BASE = ( + "/spreadsheets/d/e/2PACX-1vR1WjUKz35-ek6SiR5droDfvPp51MTds4wUs57vEZNh2uDfihSTPhTaiiRo" + "vLbNe1mkeRgurppRJ_Zy/" +) +PATH = PATH_BASE + "pub?output=tsv" + +FILE_REDIRECT = ( + b"e@2PACX-1vR1WjUKz35-ek6SiR5droDfvPp51MTds4wUs57vEZNh2uDfihSTPhTai" + b"iRovLbNe1mkeRgurppRJ_Zy?output=tsv" +) +RELATIVE_RELATIVE_REDIRECT = ( + b"370cmver1f290kjsnpar5ku2h9g/3llvt5u8njbvat22m9l19db1h4/1656191325000/109226138307867586192/*/" + + FILE_REDIRECT +) +RELATIVE_ABSOLUTE_REDIRECT = b"/pub/70cmver1f290kjsnpar5ku2h9g/" + RELATIVE_RELATIVE_REDIRECT +ABSOLUTE_ABSOLUTE_REDIRECT = ( + b"https://doc-14-2g-sheets.googleusercontent.com" + RELATIVE_ABSOLUTE_REDIRECT +) + +# response headers returned from the initial request +HEADERS_REDIRECT = ( + b"HTTP/1.1 307 Temporary Redirect\r\n" + b"Content-Type: text/html; charset=UTF-8\r\n" + b"Cache-Control: no-cache, no-store, max-age=0, must-revalidate\r\n" + b"Pragma: no-cache\r\n" + b"Expires: Mon, 01 Jan 1990 00:00:00 GMT\r\n" + b"Date: Sat, 25 Jun 2022 21:08:48 GMT\r\n" + b"Location: NEW_LOCATION_HERE\r\n" + b'P3P: CP="This is not a P3P policy! See g.co/p3phelp for more info."\r\n' + b"X-Content-Type-Options: nosniff\r\n" + b"X-XSS-Protection: 1; mode=block\r\n" + b"Server: GSE\r\n" + b"Set-Cookie: NID=511=EcnO010Porg0NIrxM8tSG6MhfQiVtWrQS42CjhKEpzwIvzBj2PFYH0-H_N--EAXaPBkR2j" + b"FjAWEAxIJNqhvKb0vswOWp9hqcCrO51S8kO5I4C3" + b"Is2ctWe1b_ysRU-6hjnJyLHzqjXotAWzEmr_qA3bpqWDwlRaQIiC6SvxM8L0M; expires=Sun, 25-Dec-2022 " + b"21:08:48 GMT; path=/; " + b"domain=.google.com; HttpOnly\r\n" + b'Alt-Svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000,h3-Q050=":443"; ' + b'ma=2592000,h3-Q046=":443";' + b' ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"\r\n' + b"Accept-Ranges: none\r\n" + b"Vary: Accept-Encoding\r\n" + b"Transfer-Encoding: chunked\r\n\r\n" +) + +# response body returned from the initial request (needs to be chunked.) +BODY_REDIRECT = ( + b"\n\nTemporary Redirect\n\n" + b'\n' + b"

Temporary Redirect

\nThe document has moved " + b'here.\n\n\n' +) + +# response headers from the request to the redirected location +HEADERS = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/tab-separated-values\r\n" + b"X-Frame-Options: ALLOW-FROM https://docs.google.com\r\n" + b"X-Robots-Tag: noindex, nofollow, nosnippet\r\n" + b"Expires: Sat, 25 Jun 2022 21:08:49 GMT\r\n" + b"Date: Sat, 25 Jun 2022 21:08:49 GMT\r\n" + b"Cache-Control: private, max-age=300\r\n" + b'Content-Disposition: attachment; filename="WeeklyPlanner-Sheet1.tsv"; ' + b"filename*=UTF-8''Weekly%20Planner%20-%20Sheet1.tsv\r\n" + b"Access-Control-Allow-Origin: *\r\n" + b"Access-Control-Expose-Headers: Cache-Control,Content-Disposition,Content-Encoding," + b"Content-Length,Content-Type,Date,Expires,Server,Transfer-Encoding\r\n" + b"Content-Security-Policy: frame-ancestors 'self' https://docs.google.com\r\n" + b"Content-Security-Policy: base-uri 'self';object-src 'self';report-uri https://" + b"doc-14-2g-sheets.googleusercontent.com/spreadsheets/cspreport;" + b"script-src 'report-sample' 'nonce-6V57medLoq3hw2BWeyGu_A' 'unsafe-inline' 'strict-dynamic'" + b" https: http: 'unsafe-eval';worker-src 'self' blob:\r\n" + b"X-Content-Type-Options: nosniff\r\n" + b"X-XSS-Protection: 1; mode=block\r\n" + b"Server: GSE\r\n" + b'Alt-Svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000,h3-Q050=":443"; ' + b'ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443";' + b' ma=2592000,quic=":443"; ma=2592000; v="46,43"\r\n' + b"Accept-Ranges: none\r\n" + b"Vary: Accept-Encoding\r\n" + b"Transfer-Encoding: chunked\r\n\r\n" +) + +# response body from the request to the redirected location (needs to be chunked.) +BODY = ( + b"Sunday\tMonday\tTuesday\tWednesday\tThursday\tFriday\tSaturday\r\n" + b"Rest\tSpin class\tRowing\tWerewolf Twitter\tWeights\tLaundry\tPoke bowl\r\n" + b"\t\tZoom call\tShow & Tell\t\tMow lawn\tSynth Riders\r\n" + b"\t\tTacos\tAsk an Engineer\t\tTrash pickup\t\r\n" + b"\t\t\t\t\tLeg day\t\r\n" + b"\t\t\t\t\tPizza night\t" +) + + +class MocketRecvInto(mocket.Mocket): + """A special Mocket to cap the number of bytes returned from recv_into()""" + + def __init__(self, response): + super().__init__(response) + self.recv_into = mock.Mock(side_effect=self._recv_into) + + def _recv_into(self, buf, nbytes=0): + assert isinstance(nbytes, int) and nbytes >= 0 + read = nbytes if nbytes > 0 else len(buf) + remaining = len(self._response) - self._position + read = min(read, remaining, 205) + end = self._position + read + buf[:read] = self._response[self._position : end] + self._position = end + return read + + +def test_chunked_absolute_absolute_redirect(): + pool = mocket.MocketPool() + pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 443)),) + chunk = _chunk(BODY_REDIRECT, len(BODY_REDIRECT)) + chunk2 = _chunk(BODY, len(BODY)) + + redirect = HEADERS_REDIRECT.replace(b"NEW_LOCATION_HERE", ABSOLUTE_ABSOLUTE_REDIRECT) + sock1 = MocketRecvInto(redirect + chunk) + sock2 = mocket.Mocket(HEADERS + chunk2) + pool.socket.side_effect = (sock1, sock2) + + requests_session = adafruit_requests.Session(pool, mocket.SSLContext()) + response = requests_session.get("https://" + HOST + PATH) + + sock1.connect.assert_called_once_with((HOST, 443)) + sock2.connect.assert_called_once_with(("doc-14-2g-sheets.googleusercontent.com", 443)) + sock2.send.assert_has_calls([mock.call(RELATIVE_ABSOLUTE_REDIRECT[1:])]) + + assert response.text == str(BODY, "utf-8") + + +def test_chunked_relative_absolute_redirect(): + pool = mocket.MocketPool() + pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 443)),) + chunk = _chunk(BODY_REDIRECT, len(BODY_REDIRECT)) + chunk2 = _chunk(BODY, len(BODY)) + + redirect = HEADERS_REDIRECT.replace(b"NEW_LOCATION_HERE", RELATIVE_ABSOLUTE_REDIRECT) + sock1 = MocketRecvInto(redirect + chunk) + sock2 = mocket.Mocket(HEADERS + chunk2) + pool.socket.side_effect = (sock1, sock2) + + requests_session = adafruit_requests.Session(pool, mocket.SSLContext()) + response = requests_session.get("https://" + HOST + PATH) + + sock1.connect.assert_called_once_with((HOST, 443)) + sock2.connect.assert_called_once_with(("docs.google.com", 443)) + sock2.send.assert_has_calls([mock.call(RELATIVE_ABSOLUTE_REDIRECT[1:])]) + + assert response.text == str(BODY, "utf-8") + + +def test_chunked_relative_relative_redirect(): + pool = mocket.MocketPool() + pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 443)),) + chunk = _chunk(BODY_REDIRECT, len(BODY_REDIRECT)) + chunk2 = _chunk(BODY, len(BODY)) + + redirect = HEADERS_REDIRECT.replace(b"NEW_LOCATION_HERE", RELATIVE_RELATIVE_REDIRECT) + sock1 = MocketRecvInto(redirect + chunk) + sock2 = mocket.Mocket(HEADERS + chunk2) + pool.socket.side_effect = (sock1, sock2) + + requests_session = adafruit_requests.Session(pool, mocket.SSLContext()) + response = requests_session.get("https://" + HOST + PATH) + + sock1.connect.assert_called_once_with((HOST, 443)) + sock2.connect.assert_called_once_with(("docs.google.com", 443)) + sock2.send.assert_has_calls( + [mock.call(bytes(PATH_BASE[1:], "utf-8") + RELATIVE_RELATIVE_REDIRECT)] + ) + + assert response.text == str(BODY, "utf-8") + + +def test_chunked_relative_relative_redirect_backstep(): + pool = mocket.MocketPool() + pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 443)),) + chunk = _chunk(BODY_REDIRECT, len(BODY_REDIRECT)) + chunk2 = _chunk(BODY, len(BODY)) + + remove_paths = 2 + backstep = b"../" * remove_paths + path_base_parts = PATH_BASE.split("/") + # PATH_BASE starts with "/" so skip it and also remove from the count + path_base = "/".join(path_base_parts[1 : len(path_base_parts) - remove_paths - 1]) + "/" + + redirect = HEADERS_REDIRECT.replace(b"NEW_LOCATION_HERE", backstep + RELATIVE_RELATIVE_REDIRECT) + sock1 = MocketRecvInto(redirect + chunk) + sock2 = mocket.Mocket(HEADERS + chunk2) + pool.socket.side_effect = (sock1, sock2) + + requests_session = adafruit_requests.Session(pool, mocket.SSLContext()) + response = requests_session.get("https://" + HOST + PATH) + + sock1.connect.assert_called_once_with((HOST, 443)) + sock2.connect.assert_called_once_with(("docs.google.com", 443)) + sock2.send.assert_has_calls([mock.call(bytes(path_base, "utf-8") + RELATIVE_RELATIVE_REDIRECT)]) + + assert response.text == str(BODY, "utf-8") + + +def test_chunked_allow_redirects_false(): + pool = mocket.MocketPool() + pool.getaddrinfo.return_value = ((None, None, None, None, (IP, 443)),) + chunk = _chunk(BODY_REDIRECT, len(BODY_REDIRECT)) + chunk2 = _chunk(BODY, len(BODY)) + + redirect = HEADERS_REDIRECT.replace(b"NEW_LOCATION_HERE", ABSOLUTE_ABSOLUTE_REDIRECT) + sock1 = MocketRecvInto(redirect + chunk) + sock2 = mocket.Mocket(HEADERS + chunk2) + pool.socket.side_effect = (sock1, sock2) + + requests_session = adafruit_requests.Session(pool, mocket.SSLContext()) + response = requests_session.get("https://" + HOST + PATH, allow_redirects=False) + + sock1.connect.assert_called_once_with((HOST, 443)) + sock2.connect.assert_not_called() + + assert response.text == str(BODY_REDIRECT, "utf-8") diff --git a/tests/concurrent_test.py b/tests/concurrent_test.py index b857418..d24e3c7 100644 --- a/tests/concurrent_test.py +++ b/tests/concurrent_test.py @@ -1,47 +1,46 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +"""Concurrent Tests""" + +import errno from unittest import mock + import mocket -import pytest -import errno -import adafruit_requests - -ip = "1.2.3.4" -host = "wifitest.adafruit.com" -host2 = "wifitest2.adafruit.com" -path = "/testwifi/index.html" -text = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" -response = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + text - - -def test_second_connect_fails_memoryerror(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(response) - sock2 = mocket.Mocket(response) - sock3 = mocket.Mocket(response) + + +def test_second_connect_fails_memoryerror(pool, requests_ssl): + sock = mocket.Mocket() + sock2 = mocket.Mocket() + sock3 = mocket.Mocket() pool.socket.call_count = 0 # Reset call count pool.socket.side_effect = [sock, sock2, sock3] sock2.connect.side_effect = MemoryError() - ssl = mocket.SSLContext() - - s = adafruit_requests.Session(pool, ssl) - r = s.get("https://" + host + path) + response = requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_1) sock.send.assert_has_calls( - [mock.call(b"testwifi/index.html"),] + [ + mock.call(b"testwifi/index.html"), + ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"), mock.call(b"\r\n"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + mock.call(b"\r\n"), + ] ) - assert r.text == str(text, "utf-8") + assert response.text == str(mocket.MOCK_RESPONSE_TEXT, "utf-8") - host2 = "test.adafruit.com" - s.get("https://" + host2 + path) + requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_2) - sock.connect.assert_called_once_with((host, 443)) - sock2.connect.assert_called_once_with((host2, 443)) - sock3.connect.assert_called_once_with((host2, 443)) + sock.connect.assert_called_once_with((mocket.MOCK_HOST_1, 443)) + sock2.connect.assert_called_once_with((mocket.MOCK_HOST_2, 443)) + sock3.connect.assert_called_once_with((mocket.MOCK_HOST_2, 443)) # Make sure that the socket is closed after send fails. sock.close.assert_called_once() sock2.close.assert_called_once() @@ -49,36 +48,37 @@ def test_second_connect_fails_memoryerror(): assert pool.socket.call_count == 3 -def test_second_connect_fails_oserror(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(response) - sock2 = mocket.Mocket(response) - sock3 = mocket.Mocket(response) +def test_second_connect_fails_oserror(pool, requests_ssl): + sock = mocket.Mocket() + sock2 = mocket.Mocket() + sock3 = mocket.Mocket() pool.socket.call_count = 0 # Reset call count pool.socket.side_effect = [sock, sock2, sock3] sock2.connect.side_effect = OSError(errno.ENOMEM) - ssl = mocket.SSLContext() - - s = adafruit_requests.Session(pool, ssl) - r = s.get("https://" + host + path) + response = requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_1) sock.send.assert_has_calls( - [mock.call(b"testwifi/index.html"),] + [ + mock.call(b"testwifi/index.html"), + ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"), mock.call(b"\r\n"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + mock.call(b"\r\n"), + ] ) - assert r.text == str(text, "utf-8") + assert response.text == str(mocket.MOCK_RESPONSE_TEXT, "utf-8") - host2 = "test.adafruit.com" - s.get("https://" + host2 + path) + requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_2) - sock.connect.assert_called_once_with((host, 443)) - sock2.connect.assert_called_once_with((host2, 443)) - sock3.connect.assert_called_once_with((host2, 443)) + sock.connect.assert_called_once_with((mocket.MOCK_HOST_1, 443)) + sock2.connect.assert_called_once_with((mocket.MOCK_HOST_2, 443)) + sock3.connect.assert_called_once_with((mocket.MOCK_HOST_2, 443)) # Make sure that the socket is closed after send fails. sock.close.assert_called_once() sock2.close.assert_called_once() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..02aa1a6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: 2023 Justin Myers for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +"""PyTest Setup""" + +import adafruit_connection_manager +import mocket +import pytest + +import adafruit_requests + + +@pytest.fixture(autouse=True) +def reset_connection_manager(monkeypatch): + """Reset the ConnectionManager, since it's a singlton and will hold data""" + monkeypatch.setattr( + "adafruit_requests.get_connection_manager", + adafruit_connection_manager.ConnectionManager, + ) + + +@pytest.fixture +def sock(): + return mocket.Mocket(mocket.MOCK_RESPONSE) + + +@pytest.fixture +def pool(sock): + pool = mocket.MocketPool() + pool.getaddrinfo.return_value = ((None, None, None, None, (mocket.MOCK_POOL_IP, 80)),) + pool.socket.return_value = sock + return pool + + +@pytest.fixture +def requests(pool): + return adafruit_requests.Session(pool) + + +@pytest.fixture +def requests_ssl(pool): + ssl_context = mocket.SSLContext() + return adafruit_requests.Session(pool, ssl_context) diff --git a/tests/files/green_red.png b/tests/files/green_red.png new file mode 100644 index 0000000..532c956 Binary files /dev/null and b/tests/files/green_red.png differ diff --git a/tests/files/green_red.png.license b/tests/files/green_red.png.license new file mode 100644 index 0000000..d41b03e --- /dev/null +++ b/tests/files/green_red.png.license @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers +# SPDX-License-Identifier: Unlicense diff --git a/tests/files/red_green.png b/tests/files/red_green.png new file mode 100644 index 0000000..9d3bdb6 Binary files /dev/null and b/tests/files/red_green.png differ diff --git a/tests/files/red_green.png.license b/tests/files/red_green.png.license new file mode 100644 index 0000000..d41b03e --- /dev/null +++ b/tests/files/red_green.png.license @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers +# SPDX-License-Identifier: Unlicense diff --git a/tests/files_test.py b/tests/files_test.py new file mode 100644 index 0000000..5a81877 --- /dev/null +++ b/tests/files_test.py @@ -0,0 +1,237 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers +# +# SPDX-License-Identifier: Unlicense + +"""Post Files Tests""" +# pylint: disable=line-too-long + +import re +from unittest import mock + +import mocket +import pytest +import requests as python_requests +from local_test_server import uses_local_server + + +@pytest.fixture +def log_stream(): + return [] + + +@pytest.fixture +def post_url(): + return "http://127.0.0.1:5000/post" + + +@pytest.fixture +def request_logging(log_stream): + """Reset the ConnectionManager, since it's a singlton and will hold data""" + import http.client # pylint: disable=import-outside-toplevel + + def httpclient_log(*args): + log_stream.append(args) + + http.client.print = httpclient_log + http.client.HTTPConnection.debuglevel = 1 + + +def get_actual_request_data(log_stream): + boundary_pattern = r"(?<=boundary=)(.\w*)" + content_length_pattern = r"(?<=Content-Length: )(.\d*)" + + boundary = "" + actual_request_post = "" + content_length = "" + for log in log_stream: + for log_arg in log: + boundary_search = re.findall(boundary_pattern, log_arg) + content_length_search = re.findall(content_length_pattern, log_arg) + if boundary_search: + boundary = boundary_search[0] + if content_length_search: + content_length = content_length_search[0] + if "Content-Disposition" in log_arg or "\\x" in log_arg: + # this will look like: + # b\'{content}\' + # and escaped characters look like: + # \\r + post_data = log_arg[2:-1] + post_bytes = post_data.encode("utf-8") + post_unescaped = post_bytes.decode("unicode_escape") + actual_request_post = post_unescaped.encode("latin1") + + return boundary, content_length, actual_request_post + + +@uses_local_server +def test_post_file_as_data( # pylint: disable=unused-argument + requests, sock, log_stream, post_url, request_logging +): + with open("tests/files/red_green.png", "rb") as file_1: + python_requests.post(post_url, data=file_1, timeout=30) + __, content_length, actual_request_post = get_actual_request_data(log_stream) + + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", data=file_1) + + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Length"), + mock.call(b": "), + mock.call(content_length.encode()), + mock.call(b"\r\n"), + ] + ) + sent = b"".join(sock.sent_data) + assert sent.endswith(actual_request_post) + + +@uses_local_server +def test_post_files_text( # pylint: disable=unused-argument + sock, requests, log_stream, post_url, request_logging +): + file_data = { + "key_4": (None, "Value 5"), + } + + python_requests.post(post_url, files=file_data, timeout=30) + boundary, content_length, actual_request_post = get_actual_request_data(log_stream) + + requests._build_boundary_string = mock.Mock(return_value=boundary) + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) + + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Type"), + mock.call(b": "), + mock.call(f"multipart/form-data; boundary={boundary}".encode()), + mock.call(b"\r\n"), + ] + ) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Length"), + mock.call(b": "), + mock.call(content_length.encode()), + mock.call(b"\r\n"), + ] + ) + + sent = b"".join(sock.sent_data) + assert sent.endswith(actual_request_post) + + +@uses_local_server +def test_post_files_file( # pylint: disable=unused-argument + sock, requests, log_stream, post_url, request_logging +): + with open("tests/files/red_green.png", "rb") as file_1: + file_data = { + "file_1": ( + "red_green.png", + file_1, + "image/png", + { + "Key_1": "Value 1", + "Key_2": "Value 2", + "Key_3": "Value 3", + }, + ), + } + + python_requests.post(post_url, files=file_data, timeout=30) + boundary, content_length, actual_request_post = get_actual_request_data(log_stream) + + requests._build_boundary_string = mock.Mock(return_value=boundary) + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) + + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Type"), + mock.call(b": "), + mock.call(f"multipart/form-data; boundary={boundary}".encode()), + mock.call(b"\r\n"), + ] + ) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Length"), + mock.call(b": "), + mock.call(content_length.encode()), + mock.call(b"\r\n"), + ] + ) + sent = b"".join(sock.sent_data) + assert sent.endswith(actual_request_post) + + +@uses_local_server +def test_post_files_complex( # pylint: disable=unused-argument + sock, requests, log_stream, post_url, request_logging +): + with open("tests/files/red_green.png", "rb") as file_1, open( + "tests/files/green_red.png", "rb" + ) as file_2: + file_data = { + "file_1": ( + "red_green.png", + file_1, + "image/png", + { + "Key_1": "Value 1", + "Key_2": "Value 2", + "Key_3": "Value 3", + }, + ), + "key_4": (None, "Value 5"), + "file_2": ( + "green_red.png", + file_2, + "image/png", + ), + "key_6": (None, "Value 6"), + } + + python_requests.post(post_url, files=file_data, timeout=30) + boundary, content_length, actual_request_post = get_actual_request_data(log_stream) + + requests._build_boundary_string = mock.Mock(return_value=boundary) + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) + + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Type"), + mock.call(b": "), + mock.call(f"multipart/form-data; boundary={boundary}".encode()), + mock.call(b"\r\n"), + ] + ) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Length"), + mock.call(b": "), + mock.call(content_length.encode()), + mock.call(b"\r\n"), + ] + ) + sent = b"".join(sock.sent_data) + assert sent.endswith(actual_request_post) + + +def test_post_files_not_binary(requests): + with open("tests/files/red_green.png") as file_1: + file_data = { + "file_1": ( + "red_green.png", + file_1, + "image/png", + ), + } + + with pytest.raises(ValueError) as context: + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", files=file_data) + assert "Files must be opened in binary mode" in str(context) diff --git a/tests/header_test.py b/tests/header_test.py index 46dcc76..69c16c5 100644 --- a/tests/header_test.py +++ b/tests/header_test.py @@ -1,32 +1,87 @@ -from unittest import mock +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +"""Header Tests""" + import mocket -import json -import adafruit_requests +import pytest + + +def test_check_headers_not_dict(requests): + with pytest.raises(TypeError) as context: + requests._check_headers("") + assert "Headers must be in dict format" in str(context) + + +def test_check_headers_not_valid(requests): + with pytest.raises(TypeError) as context: + requests._check_headers({"Good1": "a", "Good2": b"b", "Good3": None, "Bad1": True}) + assert "Header part (True) from Bad1 must be of type str or bytes" in str(context) + + +def test_check_headers_valid(requests): + requests._check_headers({"Good1": "a", "Good2": b"b", "Good3": None}) + assert True + + +def test_host(sock, requests): + headers = {} + requests.get("http://" + mocket.MOCK_HOST_1 + "/get", headers=headers) -ip = "1.2.3.4" -host = "httpbin.org" -response_headers = b"HTTP/1.0 200 OK\r\nContent-Length: 0\r\n\r\n" + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sent = b"".join(sock.sent_data) + assert b"Host: wifitest.adafruit.com\r\n" in sent -def test_json(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(response_headers) - pool.socket.return_value = sock - sent = [] +def test_host_replace(sock, requests): + headers = {"host": mocket.MOCK_POOL_IP} + requests.get("http://" + mocket.MOCK_HOST_1 + "/get", headers=headers) - def _send(data): - sent.append(data) - return len(data) + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sent = b"".join(sock.sent_data) + assert b"host: 10.10.10.10\r\n" in sent + assert b"Host: wifitest.adafruit.com\r\n" not in sent + assert sent.lower().count(b"host:") == 1 - sock.send.side_effect = _send - s = adafruit_requests.Session(pool) +def test_user_agent(sock, requests): + headers = {} + requests.get("http://" + mocket.MOCK_HOST_1 + "/get", headers=headers) + + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sent = b"".join(sock.sent_data) + assert b"User-Agent: Adafruit CircuitPython\r\n" in sent + + +def test_user_agent_replace(sock, requests): headers = {"user-agent": "blinka/1.0.0"} - r = s.get("http://" + host + "/get", headers=headers) + requests.get("http://" + mocket.MOCK_HOST_1 + "/get", headers=headers) - sock.connect.assert_called_once_with((ip, 80)) - sent = b"".join(sent).lower() + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sent = b"".join(sock.sent_data) assert b"user-agent: blinka/1.0.0\r\n" in sent - # The current implementation sends two user agents. Fix it, and uncomment below. - # assert sent.count(b"user-agent:") == 1 + assert b"User-Agent: Adafruit CircuitPython\r\n" not in sent + assert sent.lower().count(b"user-agent:") == 1 + + +def test_content_type(sock, requests): + headers = {} + data = {"test": True} + requests.post("http://" + mocket.MOCK_HOST_1 + "/get", data=data, headers=headers) + + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sent = b"".join(sock.sent_data) + assert b"Content-Type: application/x-www-form-urlencoded\r\n" in sent + + +def test_content_type_replace(sock, requests): + headers = {"content-type": "application/test"} + data = {"test": True} + requests.post("http://" + mocket.MOCK_HOST_1 + "/get", data=data, headers=headers) + + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sent = b"".join(sock.sent_data) + assert b"content-type: application/test\r\n" in sent + assert b"Content-Type: application/x-www-form-urlencoded\r\n" not in sent + assert sent.lower().count(b"content-type:") == 1 diff --git a/tests/legacy_mocket.py b/tests/legacy_mocket.py deleted file mode 100644 index 40d1045..0000000 --- a/tests/legacy_mocket.py +++ /dev/null @@ -1,40 +0,0 @@ -from unittest import mock - -SOCK_STREAM = 0 - -set_interface = mock.Mock() -interface = mock.MagicMock() -getaddrinfo = mock.Mock() -socket = mock.Mock() - - -class Mocket: - def __init__(self, response): - self.settimeout = mock.Mock() - self.close = mock.Mock() - self.connect = mock.Mock() - self.send = mock.Mock(side_effect=self._send) - self.readline = mock.Mock(side_effect=self._readline) - self.recv = mock.Mock(side_effect=self._recv) - self.fail_next_send = False - self._response = response - self._position = 0 - - def _send(self, data): - if self.fail_next_send: - self.fail_next_send = False - raise RuntimeError("Send failed") - return None - - def _readline(self): - i = self._response.find(b"\r\n", self._position) - r = self._response[self._position : i + 2] - self._position = i + 2 - return r - - def _recv(self, count): - end = self._position + count - r = self._response[self._position : end] - self._position = end - print(r) - return r diff --git a/tests/legacy_test.py b/tests/legacy_test.py deleted file mode 100644 index bacdc0f..0000000 --- a/tests/legacy_test.py +++ /dev/null @@ -1,176 +0,0 @@ -from unittest import mock -import legacy_mocket as mocket -import json -import pytest -import adafruit_requests - -ip = "1.2.3.4" -host = "httpbin.org" -response = {"Date": "July 25, 2019"} -encoded = json.dumps(response).encode("utf-8") -headers = "HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n".format(len(encoded)).encode( - "utf-8" -) - - -def test_get_json(): - mocket.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(headers + encoded) - mocket.socket.return_value = sock - - adafruit_requests.set_socket(mocket, mocket.interface) - r = adafruit_requests.get("http://" + host + "/get") - - sock.connect.assert_called_once_with((ip, 80)) - assert r.json() == response - r.close() - - -def test_tls_mode(): - mocket.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(headers + encoded) - mocket.socket.return_value = sock - - adafruit_requests.set_socket(mocket, mocket.interface) - r = adafruit_requests.get("https://" + host + "/get") - - sock.connect.assert_called_once_with((host, 443), mocket.interface.TLS_MODE) - assert r.json() == response - r.close() - - -def test_post_string(): - mocket.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(headers + encoded) - mocket.socket.return_value = sock - - adafruit_requests.set_socket(mocket, mocket.interface) - data = "31F" - r = adafruit_requests.post("http://" + host + "/post", data=data) - sock.connect.assert_called_once_with((ip, 80)) - sock.send.assert_called_with(b"31F") - r.close() - - -def test_second_tls_send_fails(): - mocket.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(headers + encoded) - sock2 = mocket.Mocket(headers + encoded) - mocket.socket.call_count = 0 # Reset call count - mocket.socket.side_effect = [sock, sock2] - - adafruit_requests.set_socket(mocket, mocket.interface) - r = adafruit_requests.get("https://" + host + "/testwifi/index.html") - - sock.send.assert_has_calls( - [mock.call(b"testwifi/index.html"),] - ) - - sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(host.encode("utf-8")), mock.call(b"\r\n"),] - ) - assert r.text == str(encoded, "utf-8") - - sock.fail_next_send = True - adafruit_requests.get("https://" + host + "/get2") - - sock.connect.assert_called_once_with((host, 443), mocket.interface.TLS_MODE) - sock2.connect.assert_called_once_with((host, 443), mocket.interface.TLS_MODE) - # Make sure that the socket is closed after send fails. - sock.close.assert_called_once() - assert sock2.close.call_count == 0 - assert mocket.socket.call_count == 2 - - -def test_second_send_fails(): - mocket.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(headers + encoded) - sock2 = mocket.Mocket(headers + encoded) - mocket.socket.call_count = 0 # Reset call count - mocket.socket.side_effect = [sock, sock2] - - adafruit_requests.set_socket(mocket, mocket.interface) - r = adafruit_requests.get("http://" + host + "/testwifi/index.html") - - sock.send.assert_has_calls( - [mock.call(b"testwifi/index.html"),] - ) - - sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(host.encode("utf-8")), mock.call(b"\r\n"),] - ) - assert r.text == str(encoded, "utf-8") - - sock.fail_next_send = True - adafruit_requests.get("http://" + host + "/get2") - - sock.connect.assert_called_once_with((ip, 80)) - sock2.connect.assert_called_once_with((ip, 80)) - # Make sure that the socket is closed after send fails. - sock.close.assert_called_once() - assert sock2.close.call_count == 0 - assert mocket.socket.call_count == 2 - - -def test_first_read_fails(): - mocket.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(b"") - sock2 = mocket.Mocket(headers + encoded) - mocket.socket.call_count = 0 # Reset call count - mocket.socket.side_effect = [sock, sock2] - - adafruit_requests.set_socket(mocket, mocket.interface) - - r = adafruit_requests.get("http://" + host + "/testwifi/index.html") - - sock.send.assert_has_calls( - [mock.call(b"testwifi/index.html"),] - ) - - sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(host.encode("utf-8")), mock.call(b"\r\n"),] - ) - - sock2.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(host.encode("utf-8")), mock.call(b"\r\n"),] - ) - - sock.connect.assert_called_once_with((ip, 80)) - sock2.connect.assert_called_once_with((ip, 80)) - # Make sure that the socket is closed after the first receive fails. - sock.close.assert_called_once() - assert mocket.socket.call_count == 2 - - -def test_second_tls_connect_fails(): - mocket.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(headers + encoded) - sock2 = mocket.Mocket(headers + encoded) - sock3 = mocket.Mocket(headers + encoded) - mocket.socket.call_count = 0 # Reset call count - mocket.socket.side_effect = [sock, sock2, sock3] - sock2.connect.side_effect = RuntimeError("error connecting") - - adafruit_requests.set_socket(mocket, mocket.interface) - r = adafruit_requests.get("https://" + host + "/testwifi/index.html") - - sock.send.assert_has_calls( - [mock.call(b"testwifi/index.html"),] - ) - - sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(host.encode("utf-8")), mock.call(b"\r\n"),] - ) - assert r.text == str(encoded, "utf-8") - - host2 = "test.adafruit.com" - r = adafruit_requests.get("https://" + host2 + "/get2") - - sock.connect.assert_called_once_with((host, 443), mocket.interface.TLS_MODE) - sock2.connect.assert_called_once_with((host2, 443), mocket.interface.TLS_MODE) - sock3.connect.assert_called_once_with((host2, 443), mocket.interface.TLS_MODE) - # Make sure that the socket is closed after send fails. - sock.close.assert_called_once() - sock2.close.assert_called_once() - assert sock3.close.call_count == 0 - assert mocket.socket.call_count == 3 diff --git a/tests/local_test_server.py b/tests/local_test_server.py new file mode 100644 index 0000000..04f47ce --- /dev/null +++ b/tests/local_test_server.py @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: 2025 Tim Cocks +# +# SPDX-License-Identifier: MIT +import functools +import json +import socketserver +import threading +import time +from http.server import SimpleHTTPRequestHandler + + +def uses_local_server(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + with ReusableAddressTCPServer(("127.0.0.1", 5000), LocalTestServerHandler) as server: + server_thread = threading.Thread(target=server.serve_forever) + server_thread.daemon = True + server_thread.start() + time.sleep(2) # Give the server some time to start + + result = func(*args, **kwargs) + + server.shutdown() + server.server_close() + return result + + return wrapper + + +class ReusableAddressTCPServer(socketserver.TCPServer): + # Enable SO_REUSEADDR + allow_reuse_address = True + + +class LocalTestServerHandler(SimpleHTTPRequestHandler): + def do_POST(self): + if self.path == "/post": + resp_body = json.dumps({"url": "http://localhost:5000/post"}).encode("utf-8") + self.send_response(200) + self.send_header("Content-type", "application/json") + self.send_header("Content-Length", str(len(resp_body))) + self.end_headers() + self.wfile.write(resp_body) + + def do_GET(self): + if self.path == "/get": + resp_body = json.dumps({"url": "http://localhost:5000/get"}).encode("utf-8") + self.send_response(200) + self.send_header("Content-type", "application/json") + self.send_header("Content-Length", str(len(resp_body))) + self.end_headers() + self.wfile.write(resp_body) + if self.path.startswith("/status"): + try: + requested_status = int(self.path.split("/")[2]) + except ValueError: + resp_body = json.dumps({"error": "requested status code must be int"}).encode( + "utf-8" + ) + self.send_response(400) + self.send_header("Content-type", "application/json") + self.send_header("Content-Length", str(len(resp_body))) + self.end_headers() + self.wfile.write(resp_body) + return + + if requested_status != 204: + self.send_response(requested_status) + self.send_header("Content-type", "text/html") + self.send_header("Content-Length", "0") + else: + self.send_response(requested_status) + self.send_header("Content-type", "text/html") + self.end_headers() diff --git a/tests/method_test.py b/tests/method_test.py new file mode 100644 index 0000000..1f5d9cf --- /dev/null +++ b/tests/method_test.py @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +"""Post Tests""" + +from unittest import mock + +import mocket +import pytest + + +@pytest.mark.parametrize( + "call", + ( + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + ), +) +def test_methods(call, sock, requests): + method = getattr(requests, call.lower()) + method("http://" + mocket.MOCK_HOST_1 + "/" + call.lower()) + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + + sock.send.assert_has_calls( + [ + mock.call(bytes(call, "utf-8")), + mock.call(b" /"), + mock.call(bytes(call.lower(), "utf-8")), + mock.call(b" HTTP/1.1\r\n"), + ] + ) + sock.send.assert_has_calls( + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + ] + ) + + +def test_post_string(sock, requests): + data = "31F" + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", data=data) + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sock.send.assert_called_with(b"31F") + + +def test_post_form(sock, requests): + data = { + "Date": "July 25, 2019", + "Time": "12:00", + } + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", data=data) + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Type"), + mock.call(b": "), + mock.call(b"application/x-www-form-urlencoded"), + mock.call(b"\r\n"), + ] + ) + sock.send.assert_called_with(b"Date=July 25, 2019&Time=12:00") + + +def test_post_json(sock, requests): + json_data = { + "Date": "July 25, 2019", + "Time": "12:00", + } + requests.post("http://" + mocket.MOCK_HOST_1 + "/post", json=json_data) + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + sock.send.assert_has_calls( + [ + mock.call(b"Content-Type"), + mock.call(b": "), + mock.call(b"application/json"), + mock.call(b"\r\n"), + ] + ) + sock.send.assert_called_with(b'{"Date": "July 25, 2019", "Time": "12:00"}') diff --git a/tests/mocket.py b/tests/mocket.py index bc24daf..5541b49 100644 --- a/tests/mocket.py +++ b/tests/mocket.py @@ -1,16 +1,37 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +"""Mock Socket""" + from unittest import mock +MOCK_POOL_IP = "10.10.10.10" +MOCK_HOST_1 = "wifitest.adafruit.com" +MOCK_HOST_2 = "wifitest2.adafruit.com" +MOCK_PATH_1 = "/testwifi/index.html" +MOCK_ENDPOINT_1 = MOCK_HOST_1 + MOCK_PATH_1 +MOCK_ENDPOINT_2 = MOCK_HOST_2 + MOCK_PATH_1 +MOCK_RESPONSE_TEXT = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" +MOCK_RESPONSE = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + MOCK_RESPONSE_TEXT + class MocketPool: + """Mock SocketPool""" + SOCK_STREAM = 0 - def __init__(self): + def __init__(self, radio=None): self.getaddrinfo = mock.Mock() + self.getaddrinfo.return_value = ((None, None, None, None, (MOCK_POOL_IP, 80)),) self.socket = mock.Mock() class Mocket: - def __init__(self, response): + """Mock Socket""" + + def __init__(self, response=MOCK_RESPONSE): self.settimeout = mock.Mock() self.close = mock.Mock() self.connect = mock.Mock() @@ -18,34 +39,36 @@ def __init__(self, response): self.readline = mock.Mock(side_effect=self._readline) self.recv = mock.Mock(side_effect=self._recv) self.recv_into = mock.Mock(side_effect=self._recv_into) + # Test helpers self._response = response self._position = 0 self.fail_next_send = False + self.sent_data = [] def _send(self, data): if self.fail_next_send: self.fail_next_send = False return 0 + self.sent_data.append(data) return len(data) def _readline(self): i = self._response.find(b"\r\n", self._position) - r = self._response[self._position : i + 2] + response = self._response[self._position : i + 2] self._position = i + 2 - return r + return response def _recv(self, count): end = self._position + count - r = self._response[self._position : end] + response = self._response[self._position : end] self._position = end - return r + return response def _recv_into(self, buf, nbytes=0): assert isinstance(nbytes, int) and nbytes >= 0 read = nbytes if nbytes > 0 else len(buf) remaining = len(self._response) - self._position - if read > remaining: - read = remaining + read = min(read, remaining) end = self._position + read buf[:read] = self._response[self._position : end] self._position = end @@ -53,8 +76,24 @@ def _recv_into(self, buf, nbytes=0): class SSLContext: + """Mock SSL Context""" + def __init__(self): self.wrap_socket = mock.Mock(side_effect=self._wrap_socket) def _wrap_socket(self, sock, server_hostname=None): return sock + + +class MockRadio: + class Radio: + pass + + class ESP_SPIcontrol: + TLS_MODE = 2 + + class WIZNET5K: + pass + + class Unsupported: + pass diff --git a/tests/parse_test.py b/tests/parse_test.py index bef739e..7cff9f5 100644 --- a/tests/parse_test.py +++ b/tests/parse_test.py @@ -1,28 +1,30 @@ -from unittest import mock -import mocket +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +"""Parse Tests""" + import json + +import mocket + import adafruit_requests -ip = "1.2.3.4" -host = "httpbin.org" -response = {"Date": "July 25, 2019"} -encoded = json.dumps(response).encode("utf-8") +RESPONSE = {"Date": "July 25, 2019"} +ENCODED = json.dumps(RESPONSE).encode("utf-8") # Padding here tests the case where a header line is exactly 32 bytes buffered by # aligning the Content-Type header after it. -headers = "HTTP/1.0 200 OK\r\npadding: 000\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n".format( - len(encoded) -).encode( - "utf-8" -) +HEADERS = ( + "HTTP/1.0 200 OK\r\npadding: 000\r\n" + f"Content-Type: application/json\r\nContent-Length: {len(ENCODED)}\r\n\r\n" +).encode() -def test_json(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(headers + encoded) +def test_json(pool): + sock = mocket.Mocket(HEADERS + ENCODED) pool.socket.return_value = sock - s = adafruit_requests.Session(pool) - r = s.get("http://" + host + "/get") - sock.connect.assert_called_once_with((ip, 80)) - assert r.json() == response + requests_session = adafruit_requests.Session(pool) + response = requests_session.get("http://" + mocket.MOCK_HOST_1 + "/get") + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) + assert response.json() == RESPONSE diff --git a/tests/post_test.py b/tests/post_test.py deleted file mode 100644 index c2a9b7e..0000000 --- a/tests/post_test.py +++ /dev/null @@ -1,74 +0,0 @@ -from unittest import mock -import mocket -import json -import adafruit_requests - -ip = "1.2.3.4" -host = "httpbin.org" -response = {} -encoded = json.dumps(response).encode("utf-8") -headers = "HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n".format(len(encoded)).encode( - "utf-8" -) - - -def test_method(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(headers + encoded) - pool.socket.return_value = sock - - s = adafruit_requests.Session(pool) - r = s.post("http://" + host + "/post") - sock.connect.assert_called_once_with((ip, 80)) - - sock.send.assert_has_calls( - [ - mock.call(b"POST"), - mock.call(b" /"), - mock.call(b"post"), - mock.call(b" HTTP/1.1\r\n"), - ] - ) - sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"httpbin.org"),] - ) - - -def test_string(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(headers + encoded) - pool.socket.return_value = sock - - s = adafruit_requests.Session(pool) - data = "31F" - r = s.post("http://" + host + "/post", data=data) - sock.connect.assert_called_once_with((ip, 80)) - sock.send.assert_called_with(b"31F") - - -def test_form(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(headers + encoded) - pool.socket.return_value = sock - - s = adafruit_requests.Session(pool) - data = {"Date": "July 25, 2019"} - r = s.post("http://" + host + "/post", data=data) - sock.connect.assert_called_once_with((ip, 80)) - sock.send.assert_called_with(b"Date=July 25, 2019") - - -def test_json(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(headers + encoded) - pool.socket.return_value = sock - - s = adafruit_requests.Session(pool) - json_data = {"Date": "July 25, 2019"} - r = s.post("http://" + host + "/post", json=json_data) - sock.connect.assert_called_once_with((ip, 80)) - sock.send.assert_called_with(b'{"Date": "July 25, 2019"}') diff --git a/tests/protocol_test.py b/tests/protocol_test.py index 5857ad3..e02c462 100644 --- a/tests/protocol_test.py +++ b/tests/protocol_test.py @@ -1,37 +1,24 @@ -from unittest import mock -import mocket -import pytest -import adafruit_requests +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense -ip = "1.2.3.4" -host = "wifitest.adafruit.com" -path = "/testwifi/index.html" -text = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" -response = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + text +"""Protocol Tests""" +from unittest import mock -def test_get_https_no_ssl(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(response) - pool.socket.return_value = sock +import mocket +import pytest - s = adafruit_requests.Session(pool) - with pytest.raises(RuntimeError): - r = s.get("https://" + host + path) +def test_get_https_no_ssl(requests): + with pytest.raises(ValueError): + requests.get("https://" + mocket.MOCK_ENDPOINT_1) -def test_get_https_text(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(response) - pool.socket.return_value = sock - ssl = mocket.SSLContext() - s = adafruit_requests.Session(pool, ssl) - r = s.get("https://" + host + path) +def test_get_https_text(sock, requests_ssl): + response = requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_1) - sock.connect.assert_called_once_with((host, 443)) + sock.connect.assert_called_once_with((mocket.MOCK_HOST_1, 443)) sock.send.assert_has_calls( [ @@ -42,24 +29,22 @@ def test_get_https_text(): ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + ] ) - assert r.text == str(text, "utf-8") + assert response.text == str(mocket.MOCK_RESPONSE_TEXT, "utf-8") # Close isn't needed but can be called to release the socket early. - r.close() - + response.close() -def test_get_http_text(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(response) - pool.socket.return_value = sock - s = adafruit_requests.Session(pool) - r = s.get("http://" + host + path) +def test_get_http_text(sock, requests): + response = requests.get("http://" + mocket.MOCK_ENDPOINT_1) - sock.connect.assert_called_once_with((ip, 80)) + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) sock.send.assert_has_calls( [ @@ -70,22 +55,20 @@ def test_get_http_text(): ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + ] ) - assert r.text == str(text, "utf-8") + assert response.text == str(mocket.MOCK_RESPONSE_TEXT, "utf-8") -def test_get_close(): +def test_get_close(sock, requests): """Test that a response can be closed without the contents being accessed.""" - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(response) - pool.socket.return_value = sock + response = requests.get("http://" + mocket.MOCK_ENDPOINT_1) - s = adafruit_requests.Session(pool) - r = s.get("http://" + host + path) - - sock.connect.assert_called_once_with((ip, 80)) + sock.connect.assert_called_once_with((mocket.MOCK_POOL_IP, 80)) sock.send.assert_has_calls( [ @@ -96,6 +79,10 @@ def test_get_close(): ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + ] ) - r.close() + response.close() diff --git a/tests/real_call_test.py b/tests/real_call_test.py new file mode 100644 index 0000000..fad0178 --- /dev/null +++ b/tests/real_call_test.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: 2024 Justin Myers +# +# SPDX-License-Identifier: Unlicense + +"""Real call Tests""" + +import socket +import socketserver +import ssl +import threading +import time + +import adafruit_connection_manager +import pytest +from local_test_server import uses_local_server + +import adafruit_requests + + +@uses_local_server +def test_gets(): + path_index = 0 + status_code_index = 1 + text_result_index = 2 + json_keys_index = 3 + cases = [ + ("get", 200, None, {"url": "http://localhost:5000/get"}), + ("status/200", 200, "", None), + ("status/204", 204, "", None), + ] + + for case in cases: + requests = adafruit_requests.Session(socket, ssl.create_default_context()) + with requests.get(f"http://127.0.0.1:5000/{case[path_index]}") as response: + assert response.status_code == case[status_code_index] + if case[text_result_index] is not None: + assert response.text == case[text_result_index] + if case[json_keys_index] is not None: + for key, value in case[json_keys_index].items(): + assert response.json()[key] == value + + adafruit_connection_manager.connection_manager_close_all(release_references=True) + + +@pytest.mark.parametrize( + ("allow_redirects", "status_code"), + ( + (True, 200), + (False, 301), + ), +) +def test_http_to_https_redirect(allow_redirects, status_code): + url = "http://www.adafruit.com/api/quotes.php" + requests = adafruit_requests.Session(socket, ssl.create_default_context()) + with requests.get(url, allow_redirects=allow_redirects) as response: + assert response.status_code == status_code + + +def test_https_direct(): + url = "https://www.adafruit.com/api/quotes.php" + requests = adafruit_requests.Session(socket, ssl.create_default_context()) + with requests.get(url) as response: + assert response.status_code == 200 diff --git a/tests/reuse_test.py b/tests/reuse_test.py index 4e10b2d..0c87bf3 100644 --- a/tests/reuse_test.py +++ b/tests/reuse_test.py @@ -1,25 +1,20 @@ +# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries +# +# SPDX-License-Identifier: Unlicense + +"""Reuse Tests""" + from unittest import mock + import mocket import pytest -import adafruit_requests - -ip = "1.2.3.4" -host = "wifitest.adafruit.com" -host2 = "wifitest2.adafruit.com" -path = "/testwifi/index.html" -text = b"This is a test of Adafruit WiFi!\r\nIf you can read this, its working :)" -response = b"HTTP/1.0 200 OK\r\nContent-Length: 70\r\n\r\n" + text -def test_get_twice(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(response + response) +def test_get_twice(pool, requests_ssl): + sock = mocket.Mocket(mocket.MOCK_RESPONSE + mocket.MOCK_RESPONSE) pool.socket.return_value = sock - ssl = mocket.SSLContext() - s = adafruit_requests.Session(pool, ssl) - r = s.get("https://" + host + path) + response = requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_1) sock.send.assert_has_calls( [ @@ -30,11 +25,15 @@ def test_get_twice(): ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + ] ) - assert r.text == str(text, "utf-8") + assert response.text == str(mocket.MOCK_RESPONSE_TEXT, "utf-8") - r = s.get("https://" + host + path + "2") + response = requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_1 + "2") sock.send.assert_has_calls( [ @@ -45,23 +44,30 @@ def test_get_twice(): ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + ] ) - assert r.text == str(text, "utf-8") - sock.connect.assert_called_once_with((host, 443)) + assert response.text == str(mocket.MOCK_RESPONSE_TEXT, "utf-8") + sock.connect.assert_called_once_with((mocket.MOCK_HOST_1, 443)) pool.socket.assert_called_once() -def test_get_twice_after_second(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(response + response) +def test_get_twice_after_second(pool, requests_ssl): + sock = mocket.Mocket( + b"H" + b"TTP/1.0 200 OK\r\nContent-Length: " + b"70\r\n\r\nHTTP/1.0 2" + b"H" + b"TTP/1.0 200 OK\r\nContent-Length: " + b"70\r\n\r\nHTTP/1.0 2" + ) pool.socket.return_value = sock - ssl = mocket.SSLContext() - s = adafruit_requests.Session(pool, ssl) - r = s.get("https://" + host + path) + response = requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_1) sock.send.assert_has_calls( [ @@ -72,10 +78,14 @@ def test_get_twice_after_second(): ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + ] ) - r2 = s.get("https://" + host + path + "2") + requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_1 + "2") sock.send.assert_has_calls( [ @@ -86,27 +96,28 @@ def test_get_twice_after_second(): ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + ] ) - sock.connect.assert_called_once_with((host, 443)) + sock.connect.assert_called_once_with((mocket.MOCK_HOST_1, 443)) pool.socket.assert_called_once() - with pytest.raises(RuntimeError): - r.text == str(text, "utf-8") + with pytest.raises(RuntimeError) as context: + result = response.text # noqa: F841 Local variable not used + assert "Newer Response closed this one. Use Responses immediately." in str(context) -def test_connect_out_of_memory(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(response) - sock2 = mocket.Mocket(response) - sock3 = mocket.Mocket(response) +def test_connect_out_of_memory(pool, requests_ssl): + sock = mocket.Mocket() + sock2 = mocket.Mocket() + sock3 = mocket.Mocket() pool.socket.side_effect = [sock, sock2, sock3] sock2.connect.side_effect = MemoryError() - ssl = mocket.SSLContext() - s = adafruit_requests.Session(pool, ssl) - r = s.get("https://" + host + path) + response = requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_1) sock.send.assert_has_calls( [ @@ -117,11 +128,15 @@ def test_connect_out_of_memory(): ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + ] ) - assert r.text == str(text, "utf-8") + assert response.text == str(mocket.MOCK_RESPONSE_TEXT, "utf-8") - r = s.get("https://" + host2 + path) + response = requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_2) sock3.send.assert_has_calls( [ mock.call(b"GET"), @@ -131,72 +146,80 @@ def test_connect_out_of_memory(): ] ) sock3.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest2.adafruit.com"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest2.adafruit.com"), + ] ) - assert r.text == str(text, "utf-8") + assert response.text == str(mocket.MOCK_RESPONSE_TEXT, "utf-8") sock.close.assert_called_once() - sock.connect.assert_called_once_with((host, 443)) - sock3.connect.assert_called_once_with((host2, 443)) + sock.connect.assert_called_once_with((mocket.MOCK_HOST_1, 443)) + sock3.connect.assert_called_once_with((mocket.MOCK_HOST_2, 443)) -def test_second_send_fails(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(response) - sock2 = mocket.Mocket(response) +def test_second_send_fails(pool, requests_ssl): + sock = mocket.Mocket() + sock2 = mocket.Mocket() pool.socket.side_effect = [sock, sock2] - ssl = mocket.SSLContext() - - s = adafruit_requests.Session(pool, ssl) - r = s.get("https://" + host + path) + response = requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_1) sock.send.assert_has_calls( - [mock.call(b"testwifi/index.html"),] + [ + mock.call(b"testwifi/index.html"), + ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"), mock.call(b"\r\n"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + mock.call(b"\r\n"), + ] ) - assert r.text == str(text, "utf-8") + assert response.text == str(mocket.MOCK_RESPONSE_TEXT, "utf-8") sock.fail_next_send = True - s.get("https://" + host + path + "2") + requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_1 + "2") - sock.connect.assert_called_once_with((host, 443)) - sock2.connect.assert_called_once_with((host, 443)) + sock.connect.assert_called_once_with((mocket.MOCK_HOST_1, 443)) + sock2.connect.assert_called_once_with((mocket.MOCK_HOST_1, 443)) # Make sure that the socket is closed after send fails. sock.close.assert_called_once() assert sock2.close.call_count == 0 assert pool.socket.call_count == 2 -def test_second_send_lies_recv_fails(): - pool = mocket.MocketPool() - pool.getaddrinfo.return_value = ((None, None, None, None, (ip, 80)),) - sock = mocket.Mocket(response) - sock2 = mocket.Mocket(response) +def test_second_send_lies_recv_fails(pool, requests_ssl): + sock = mocket.Mocket() + sock2 = mocket.Mocket() pool.socket.side_effect = [sock, sock2] - ssl = mocket.SSLContext() - - s = adafruit_requests.Session(pool, ssl) - r = s.get("https://" + host + path) + response = requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_1) sock.send.assert_has_calls( - [mock.call(b"testwifi/index.html"),] + [ + mock.call(b"testwifi/index.html"), + ] ) sock.send.assert_has_calls( - [mock.call(b"Host: "), mock.call(b"wifitest.adafruit.com"), mock.call(b"\r\n"),] + [ + mock.call(b"Host"), + mock.call(b": "), + mock.call(b"wifitest.adafruit.com"), + mock.call(b"\r\n"), + ] ) - assert r.text == str(text, "utf-8") + assert response.text == str(mocket.MOCK_RESPONSE_TEXT, "utf-8") - s.get("https://" + host + path + "2") + requests_ssl.get("https://" + mocket.MOCK_ENDPOINT_1 + "2") - sock.connect.assert_called_once_with((host, 443)) - sock2.connect.assert_called_once_with((host, 443)) + sock.connect.assert_called_once_with((mocket.MOCK_HOST_1, 443)) + sock2.connect.assert_called_once_with((mocket.MOCK_HOST_1, 443)) # Make sure that the socket is closed after send fails. sock.close.assert_called_once() assert sock2.close.call_count == 0 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..099a9b7 --- /dev/null +++ b/tox.ini @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2022 Kevin Conley +# SPDX-FileCopyrightText: 2024 Justin Myers for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +[tox] +envlist = py311 + +[testenv] +description = run tests +deps = + pytest==7.4.3 + requests +commands = pytest + +[testenv:coverage] +description = run coverage +deps = + pytest==7.4.3 + pytest-cov==4.1.0 + requests +package = editable +commands = + coverage run --source=. --omit=tests/* --branch {posargs} -m pytest + coverage report + coverage html + +[testenv:lint] +description = run linters +deps = + pre-commit==3.6.0 +skip_install = true +commands = pre-commit run {posargs} + +[testenv:docs] +description = build docs +deps = + -r requirements.txt + -r docs/requirements.txt +skip_install = true +commands = sphinx-build -E -W -b html docs/. _build/html